From d9018d1cb58f210e2d62c476fb9bf52423acc68a Mon Sep 17 00:00:00 2001 From: Marcello DeSales Date: Mon, 1 Jun 2026 14:24:02 -0700 Subject: [PATCH 01/18] :hamister: :new: add init and server cmd --- cmd/vault-mcp-server/init.go | 19 +++++- cmd/vault-mcp-server/main.go | 128 ++++++++++++++++++++++++++++------- 2 files changed, 122 insertions(+), 25 deletions(-) diff --git a/cmd/vault-mcp-server/init.go b/cmd/vault-mcp-server/init.go index c00637b..0a82931 100644 --- a/cmd/vault-mcp-server/init.go +++ b/cmd/vault-mcp-server/init.go @@ -9,6 +9,8 @@ import ( "io" stdlog "log" "os" + "strings" + "time" "github.com/mark3labs/mcp-go/server" log "github.com/sirupsen/logrus" @@ -42,9 +44,24 @@ func initConfig() { func initLogger(outPath string) (*log.Logger, error) { logger := log.New() - logger.SetLevel(log.DebugLevel) + + // JSON format + stdout so `docker compose logs` shows structured output + // matching the other Viasat MCP tools (delinea, codedx, tenable, etc.). + logger.SetFormatter(&log.JSONFormatter{ + TimestampFormat: time.RFC3339Nano, + }) + + // Default INFO; honour LOG_LEVEL env var (debug / info / warn / error). + level := log.InfoLevel + if v := strings.ToLower(strings.TrimSpace(os.Getenv("LOG_LEVEL"))); v != "" { + if parsed, err := log.ParseLevel(v); err == nil { + level = parsed + } + } + logger.SetLevel(level) if outPath == "" { + logger.SetOutput(os.Stdout) return logger, nil } diff --git a/cmd/vault-mcp-server/main.go b/cmd/vault-mcp-server/main.go index 59e0189..83b313e 100644 --- a/cmd/vault-mcp-server/main.go +++ b/cmd/vault-mcp-server/main.go @@ -17,6 +17,7 @@ import ( "time" "github.com/hashicorp/vault-mcp-server/pkg/client" + "github.com/hashicorp/vault-mcp-server/pkg/oauth" "github.com/hashicorp/vault-mcp-server/pkg/tools" "github.com/hashicorp/vault-mcp-server/version" @@ -139,34 +140,66 @@ func httpServerInit(ctx context.Context, hcServer *server.MCPServer, logger *log opts = append(opts, server.WithTLSCert(tlsConfig.CertFile, tlsConfig.KeyFile)) } - // Log the endpoint path being used - logger.Infof("Using endpoint path: %s", endpointPath) - - // Create StreamableHTTP server which implements the new streamable-http transport - // This is the modern MCP transport that supports both direct HTTP responses and SSE streams baseStreamableServer := server.NewStreamableHTTPServer(hcServer, opts...) // Load CORS configuration corsConfig := client.LoadCORSConfigFromEnv() - // Log CORS configuration - logger.Infof("CORS Mode: %s", corsConfig.Mode) - if len(corsConfig.AllowedOrigins) > 0 { - logger.Infof("Allowed Origins: %s", strings.Join(corsConfig.AllowedOrigins, ", ")) - } else if corsConfig.Mode == "strict" { - logger.Warnf("No allowed origins configured in strict mode. All cross-origin requests will be rejected.") - } else if corsConfig.Mode == "development" { - logger.Infof("Development mode: localhost origins are automatically allowed") - } else if corsConfig.Mode == "disabled" { - logger.Warnf("CORS validation is disabled. This is not recommended for production.") - } - // Create a security wrapper around the streamable server streamableServer := client.NewSecurityHandler(baseStreamableServer, corsConfig.AllowedOrigins, corsConfig.Mode, logger) mux := http.NewServeMux() - // Apply middleware + // When MCP_AUTH_SECRET is set, the server doubles as an OAuth Authorization + // Server: MCP clients are sent through a browser login against the upstream + // Vault and the resulting Vault token is sealed into the bearer access token. + // When unset, behavior is unchanged (VAULT_TOKEN via env/header — the dev bypass). + var bearer func(http.Handler) http.Handler + oauthCfg := oauth.LoadConfigFromEnv() + if oauthCfg.Enabled() { + if err := oauthCfg.Validate(); err != nil { + return fmt.Errorf("OAuth configuration error: %w", err) + } + // Bootstrap the Viasat private CA bundle so TLS to Vault is trusted. + // Logs "private ca root ready / fetched / disabled" depending on state. + if _, err := client.EnsurePrivateCARoot(ctx, logger); err != nil { + logger.WithError(err).Warn("failed to ensure Viasat private CA bundle; continuing") + } + oauthRouter, err := oauth.NewRouter(oauthCfg, logger) + if err != nil { + return fmt.Errorf("OAuth init error: %w", err) + } + oauthRouter.Register(mux) + bearer = oauthRouter.BearerMiddleware + + if oauthCfg.OIDCCallbackPort > 0 { + callbackServer := &http.Server{ + Addr: fmt.Sprintf(":%d", oauthCfg.OIDCCallbackPort), + Handler: oauthRouter.OIDCCallbackMux(), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + } + go func() { + if err := callbackServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.WithError(err).Warn("OIDC callback server error") + } + }() + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = callbackServer.Shutdown(shutdownCtx) + }() + } + } + + // Apply middleware (innermost first). The bearer middleware unseals the OAuth + // token into the context; VaultContextMiddleware then leaves those values + // intact (it only overrides when a header/env value is present). + if bearer != nil { + streamableServer = bearer(streamableServer) + } streamableServer = client.VaultContextMiddleware(logger)(streamableServer) streamableServer = client.LoggingMiddleware(logger)(streamableServer) @@ -196,18 +229,65 @@ func httpServerInit(ctx context.Context, hcServer *server.MCPServer, logger *log if tlsConfig != nil { httpServer.TLSConfig = tlsConfig.Config - logger.Infof("TLS enabled with certificate: %s", tlsConfig.CertFile) - } else { - if !client.IsLocalHost(host) { - return fmt.Errorf("TLS is required for non-localhost binding (%s). Set MCP_TLS_CERT_FILE and MCP_TLS_KEY_FILE environment variables", host) + } else if !client.IsLocalHost(host) { + return fmt.Errorf("TLS is required for non-localhost binding (%s). Set MCP_TLS_CERT_FILE and MCP_TLS_KEY_FILE environment variables", host) + } + + // ── Bootstrap summary ──────────────────────────────────────────────────── + // Print a single structured block before starting, mirroring the pattern + // used by the other Viasat MCP tools (delinea, blackduck, tenable, etc.). + { + vaultAddr := oauthCfg.VaultAddr + if vaultAddr == "" { + vaultAddr = client.DefaultVaultAddress + } + + tlsMode := "disabled (not recommended for production)" + if tlsConfig != nil { + tlsMode = "enabled (" + tlsConfig.CertFile + ")" + } + + corsOrigins := strings.Join(corsConfig.AllowedOrigins, ", ") + if corsOrigins == "" { + corsOrigins = "(none)" + } + + logger.WithFields(log.Fields{ + "addr": addr, + "endpoint": endpointPath, + "tls": tlsMode, + "cors_mode": corsConfig.Mode, + }).Info("http server starting") + + logger.WithFields(log.Fields{ + "vault_addr": vaultAddr, + "vault_namespace": oauthCfg.VaultNamespace, + "cacert_file": client.EffectiveCACertFile(), + }).Info("vault connection") + + if oauthCfg.Enabled() { + oidcCallback := oauthCfg.OIDCCallbackURL(fmt.Sprintf("http://%s", addr)) + logger.WithFields(log.Fields{ + "login_page": fmt.Sprintf("http://%s/vault/login", addr), + "login_methods": "ldap, userpass, token, oidc", + "ldap_mount": oauthCfg.LDAPMount, + "userpass_mount": oauthCfg.UserpassMount, + "oidc_mount": oauthCfg.OIDCMount, + "oidc_role": oauthCfg.OIDCRole, + "oidc_callback": oidcCallback, + "access_token_ttl": oauthCfg.AccessTokenTTL.String(), + }).Info("oauth enabled") + } else { + logger.WithFields(log.Fields{ + "hint": "set MCP_AUTH_SECRET to enable browser login", + }).Info("oauth disabled — using VAULT_TOKEN from env/header") } - logger.Warnf("TLS is disabled on StreamableHTTP server; this is not recommended for production") } + // ── End bootstrap summary ───────────────────────────────────────────────── // Start server in goroutine errC := make(chan error, 1) go func() { - logger.Infof("Starting StreamableHTTP server on %s%s", addr, endpointPath) errC <- httpServer.ListenAndServe() }() From 5a5e192d412090e06dd3e75e13ba4c05fb793bb8 Mon Sep 17 00:00:00 2001 From: Marcello DeSales Date: Mon, 1 Jun 2026 14:24:20 -0700 Subject: [PATCH 02/18] :hamister: :recycle: add client and oauth support --- pkg/client/cabundle.go | 163 ++++++++++++++++++++++++ pkg/client/client.go | 81 ++++++++++-- pkg/client/vaultauth.go | 124 ++++++++++++++++++ pkg/oauth/config.go | 142 +++++++++++++++++++++ pkg/oauth/handlers.go | 226 +++++++++++++++++++++++++++++++++ pkg/oauth/login.go | 233 ++++++++++++++++++++++++++++++++++ pkg/oauth/metadata.go | 88 +++++++++++++ pkg/oauth/oidc.go | 175 +++++++++++++++++++++++++ pkg/oauth/pkce.go | 23 ++++ pkg/oauth/pkce_test.go | 19 +++ pkg/oauth/router.go | 150 ++++++++++++++++++++++ pkg/oauth/securetoken.go | 109 ++++++++++++++++ pkg/oauth/securetoken_test.go | 78 ++++++++++++ pkg/oauth/service.go | 123 ++++++++++++++++++ pkg/oauth/service_test.go | 72 +++++++++++ pkg/oauth/types.go | 78 ++++++++++++ 16 files changed, 1876 insertions(+), 8 deletions(-) create mode 100644 pkg/client/cabundle.go create mode 100644 pkg/client/vaultauth.go create mode 100644 pkg/oauth/config.go create mode 100644 pkg/oauth/handlers.go create mode 100644 pkg/oauth/login.go create mode 100644 pkg/oauth/metadata.go create mode 100644 pkg/oauth/oidc.go create mode 100644 pkg/oauth/pkce.go create mode 100644 pkg/oauth/pkce_test.go create mode 100644 pkg/oauth/router.go create mode 100644 pkg/oauth/securetoken.go create mode 100644 pkg/oauth/securetoken_test.go create mode 100644 pkg/oauth/service.go create mode 100644 pkg/oauth/service_test.go create mode 100644 pkg/oauth/types.go diff --git a/pkg/client/cabundle.go b/pkg/client/cabundle.go new file mode 100644 index 0000000..1d15ab7 --- /dev/null +++ b/pkg/client/cabundle.go @@ -0,0 +1,163 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package client + +import ( + "context" + "crypto/x509" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + log "github.com/sirupsen/logrus" +) + +// VIASATIOCACertURL is the env var pointing at the Viasat private CA bundle to +// fetch when the local bundle file (VIASAT_IO_CACERT_FILE) is missing. +const VIASATIOCACertURL = "VIASAT_IO_CACERT_URL" + +// CABootstrapStatus describes the state of the Viasat private CA bundle on disk. +type CABootstrapStatus struct { + Enabled bool + File string + URL string + Exists bool + SizeBytes int64 + UpdatedAt string // file mtime (RFC3339), set when the file exists on disk + LastFetchedAt string // set only when the file was fetched in this process run +} + +// EnsurePrivateCARoot guarantees the Viasat private CA bundle is available on +// disk at VIASAT_IO_CACERT_FILE so TLS to vault.seceng-iam.viasat.io is trusted. +// +// If the file already exists it is reused as-is (no network call). Only when the +// file is missing is VIASAT_IO_CACERT_URL fetched. This keeps container restarts +// cheap and lets operators prime the bundle by mounting a host directory at +// /viasat/certs (e.g. via docker-compose `volumes:`). When neither the file nor +// URL is configured this is a no-op. +func EnsurePrivateCARoot(ctx context.Context, logger *log.Logger) (CABootstrapStatus, error) { + file := getEnv(VIASATIOCACertFile, "") + url := getEnv(VIASATIOCACertURL, "") + + status := CABootstrapStatus{ + Enabled: file != "" && url != "", + File: file, + URL: url, + } + statPopulate(&status) + + if !status.Enabled { + if logger != nil { + logger.Info("private ca root bootstrap disabled (VIASAT_IO_CACERT_FILE or VIASAT_IO_CACERT_URL not set)") + } + return status, nil + } + + // Bundle already on disk — reuse without a network call. + if status.Exists { + if logger != nil { + logger.WithFields(log.Fields{ + "path": status.File, + "size_bytes": status.SizeBytes, + "updated_at": status.UpdatedAt, + "refetched": false, + }).Info("private ca root ready") + } + return status, nil + } + + // Bundle missing — fetch from the configured URL. + if logger != nil { + logger.WithFields(log.Fields{ + "path": status.File, + "url": status.URL, + }).Info("private ca root missing, fetching from url") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return status, fmt.Errorf("build viasat ca request: %w", err) + } + + httpClient := &http.Client{Timeout: 30 * time.Second} + resp, err := httpClient.Do(req) + if err != nil { + return status, fmt.Errorf("fetch viasat ca bundle: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return status, fmt.Errorf("fetch viasat ca bundle: unexpected status %s", resp.Status) + } + + pemBytes, err := io.ReadAll(resp.Body) + if err != nil { + return status, fmt.Errorf("read viasat ca bundle: %w", err) + } + + pool := x509.NewCertPool() + if ok := pool.AppendCertsFromPEM(pemBytes); !ok { + return status, fmt.Errorf("viasat ca bundle at %s did not contain PEM certificates", url) + } + + if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { + return status, fmt.Errorf("create viasat ca directory: %w", err) + } + + // Write atomically via a temp file + rename. + tmp, err := os.CreateTemp(filepath.Dir(file), "viasat-io-cacert-*.pem") + if err != nil { + return status, fmt.Errorf("create temp viasat ca file: %w", err) + } + tmpPath := tmp.Name() + cleanup := true + defer func() { + _ = tmp.Close() + if cleanup { + _ = os.Remove(tmpPath) + } + }() + + if _, err := tmp.Write(pemBytes); err != nil { + return status, fmt.Errorf("write viasat ca bundle: %w", err) + } + if err := tmp.Chmod(0o644); err != nil { + return status, fmt.Errorf("chmod viasat ca bundle: %w", err) + } + if err := tmp.Close(); err != nil { + return status, fmt.Errorf("close viasat ca bundle: %w", err) + } + if err := os.Rename(tmpPath, file); err != nil { + return status, fmt.Errorf("install viasat ca bundle: %w", err) + } + cleanup = false + + status.LastFetchedAt = time.Now().UTC().Format(time.RFC3339) + statPopulate(&status) + + if logger != nil { + logger.WithFields(log.Fields{ + "path": status.File, + "size_bytes": status.SizeBytes, + "updated_at": status.UpdatedAt, + "refetched": true, + }).Info("private ca root fetched") + } + + return status, nil +} + +func statPopulate(status *CABootstrapStatus) { + if status.File == "" { + return + } + if info, err := os.Stat(status.File); err == nil { + status.Exists = true + status.SizeBytes = info.Size() + status.UpdatedAt = info.ModTime().UTC().Format(time.RFC3339) + } +} diff --git a/pkg/client/client.go b/pkg/client/client.go index e44f9e0..a334eac 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -5,7 +5,6 @@ package client import ( "context" - "crypto/tls" "fmt" "net/http" "os" @@ -28,6 +27,12 @@ const ( VaultSkipTLSVerify = "VAULT_SKIP_VERIFY" VaultHeaderToken = "X-Vault-Token" VaultHeaderNamespace = "X-Vault-Namespace" + + // VaultCACert points at a PEM CA bundle used to verify the upstream Vault + // TLS certificate. VIASATIOCACertFile is the shared Viasat private CA bundle + // used as a fallback (and primed by the CA bootstrap, see cabundle.go). + VaultCACert = "VAULT_CACERT" + VIASATIOCACertFile = "VIASAT_IO_CACERT_FILE" ) const DefaultVaultAddress = "http://127.0.0.1:8200" @@ -35,6 +40,24 @@ const DefaultVaultAddress = "http://127.0.0.1:8200" // contextKey is a type alias to avoid lint warnings while maintaining compatibility type contextKey string +// ContextWithVaultAuth injects Vault connection details into ctx using the same +// keys VaultContextMiddleware/CreateVaultClientForSession read. It is used by the +// OAuth bearer middleware to hand the per-request Vault token (unsealed from the +// bearer access token) to the downstream session/client code. Empty values are +// skipped so existing context/header/env values are not clobbered. +func ContextWithVaultAuth(ctx context.Context, vaultAddress, vaultToken, vaultNamespace string) context.Context { + if vaultAddress != "" { + ctx = context.WithValue(ctx, contextKey(VaultAddress), vaultAddress) + } + if vaultToken != "" { + ctx = context.WithValue(ctx, contextKey(VaultToken), vaultToken) + } + if vaultNamespace != "" { + ctx = context.WithValue(ctx, contextKey(VaultNamespace), vaultNamespace) + } + return ctx +} + // getEnv retrieves the value of an environment variable or returns a fallback value if not set func getEnv(key, fallback string) string { if value, ok := os.LookupEnv(key); ok { @@ -43,28 +66,70 @@ func getEnv(key, fallback string) string { return fallback } -// NewVaultClient creates a new Vault client for the given session -func NewVaultClient(sessionId string, vaultAddress string, vaultSkipTLSVerify bool, vaultToken string, vaultNamespace string) (*api.Client, error) { - // Initialize Vault client +// EffectiveCACertFile returns the CA bundle path to trust for upstream Vault TLS, +// preferring VAULT_CACERT and falling back to the shared Viasat private CA bundle +// (VIASAT_IO_CACERT_FILE). It returns "" when neither is configured or the file +// is not present, so a default system trust store is used. +func EffectiveCACertFile() string { + for _, key := range []string{VaultCACert, VIASATIOCACertFile} { + path := getEnv(key, "") + if path == "" { + continue + } + if _, err := os.Stat(path); err == nil { + return path + } + } + return "" +} + +// newConfiguredVaultClient builds a Vault API client with TLS trust configured +// from the Viasat private CA bundle (when present) and the requested skip-verify +// behavior. It does not register the client in activeClients. +func newConfiguredVaultClient(vaultAddress string, vaultSkipTLSVerify bool, vaultToken string, vaultNamespace string) (*api.Client, error) { config := api.DefaultConfig() config.Address = vaultAddress - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: vaultSkipTLSVerify}, + // ConfigureTLS operates on the default cleanhttp *http.Transport, loading + // RootCAs from CACert (the Viasat private CA bundle when present). + tlsConfig := &api.TLSConfig{Insecure: vaultSkipTLSVerify} + if caFile := EffectiveCACertFile(); caFile != "" { + tlsConfig.CACert = caFile + } + if err := config.ConfigureTLS(tlsConfig); err != nil { + return nil, fmt.Errorf("configure Vault TLS: %w", err) + } + + // DefaultConfig reads VAULT_SKIP_VERIFY from the environment and ConfigureTLS + // only ever sets InsecureSkipVerify to true, never back to false. Force the + // caller-resolved value so an explicit skip=false wins over the env default. + if tr, ok := config.HttpClient.Transport.(*http.Transport); ok && tr.TLSClientConfig != nil { + tr.TLSClientConfig.InsecureSkipVerify = vaultSkipTLSVerify } - config.HttpClient = &http.Client{Transport: tr} client, err := api.NewClient(config) if err != nil { return nil, fmt.Errorf("api.NewClient failed to create Vault client: %v", err) } - client.SetToken(vaultToken) + if vaultToken != "" { + client.SetToken(vaultToken) + } if vaultNamespace != "" { client.SetNamespace(vaultNamespace) } + return client, nil +} + +// NewVaultClient creates a new Vault client for the given session +func NewVaultClient(sessionId string, vaultAddress string, vaultSkipTLSVerify bool, vaultToken string, vaultNamespace string) (*api.Client, error) { + client, err := newConfiguredVaultClient(vaultAddress, vaultSkipTLSVerify, vaultToken, vaultNamespace) + if err != nil { + return nil, err + } + activeClients.Store(sessionId, client) return client, nil diff --git a/pkg/client/vaultauth.go b/pkg/client/vaultauth.go new file mode 100644 index 0000000..a061d66 --- /dev/null +++ b/pkg/client/vaultauth.go @@ -0,0 +1,124 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package client + +import ( + "context" + "fmt" + + "github.com/hashicorp/vault/api" +) + +// VaultAuthParams configures how the login helpers reach the upstream Vault. +type VaultAuthParams struct { + Address string + Namespace string + SkipTLSVerify bool +} + +// loginClient builds a short-lived, unregistered Vault client for an auth call. +func loginClient(p VaultAuthParams) (*api.Client, error) { + return newConfiguredVaultClient(p.Address, p.SkipTLSVerify, "", p.Namespace) +} + +// LoginUserpass authenticates against the userpass auth method and returns the +// resulting Vault client token. +func LoginUserpass(ctx context.Context, p VaultAuthParams, mount, username, password string) (string, error) { + return loginWithPassword(ctx, p, mount, username, password) +} + +// LoginLDAP authenticates against the ldap auth method and returns the resulting +// Vault client token. +func LoginLDAP(ctx context.Context, p VaultAuthParams, mount, username, password string) (string, error) { + return loginWithPassword(ctx, p, mount, username, password) +} + +// loginWithPassword performs POST auth/{mount}/login/{username} with a password, +// which is the shape shared by the userpass and ldap auth methods. +func loginWithPassword(ctx context.Context, p VaultAuthParams, mount, username, password string) (string, error) { + vc, err := loginClient(p) + if err != nil { + return "", err + } + + path := fmt.Sprintf("auth/%s/login/%s", mount, username) + secret, err := vc.Logical().WriteWithContext(ctx, path, map[string]interface{}{ + "password": password, + }) + if err != nil { + return "", fmt.Errorf("vault login failed: %w", err) + } + if secret == nil || secret.Auth == nil || secret.Auth.ClientToken == "" { + return "", fmt.Errorf("vault login returned no client token") + } + return secret.Auth.ClientToken, nil +} + +// LookupToken validates a Vault token by calling auth/token/lookup-self with it. +// It returns nil when the token is valid. +func LookupToken(ctx context.Context, p VaultAuthParams, token string) error { + c, err := newConfiguredVaultClient(p.Address, p.SkipTLSVerify, token, p.Namespace) + if err != nil { + return err + } + if _, err := c.Auth().Token().LookupSelfWithContext(ctx); err != nil { + return fmt.Errorf("vault token lookup failed: %w", err) + } + return nil +} + +// OIDCAuthURL initiates the OIDC login flow and returns the identity provider +// authorization URL the browser should be redirected to. redirectURI must be a +// callback on this server that the Vault OIDC role permits in allowed_redirect_uris. +func OIDCAuthURL(ctx context.Context, p VaultAuthParams, mount, role, redirectURI, clientNonce string) (string, error) { + vc, err := loginClient(p) + if err != nil { + return "", err + } + + data := map[string]interface{}{ + "redirect_uri": redirectURI, + "client_nonce": clientNonce, + } + if role != "" { + data["role"] = role + } + + path := fmt.Sprintf("auth/%s/oidc/auth_url", mount) + secret, err := vc.Logical().WriteWithContext(ctx, path, data) + if err != nil { + return "", fmt.Errorf("vault oidc auth_url failed: %w", err) + } + if secret == nil || secret.Data == nil { + return "", fmt.Errorf("vault oidc auth_url returned no data") + } + authURL, _ := secret.Data["auth_url"].(string) + if authURL == "" { + return "", fmt.Errorf("vault oidc auth_url returned empty url (check role/redirect_uri)") + } + return authURL, nil +} + +// OIDCCallback completes the OIDC login flow by exchanging the IdP state/code for +// a Vault client token via GET auth/{mount}/oidc/callback. +func OIDCCallback(ctx context.Context, p VaultAuthParams, mount, state, code, clientNonce string) (string, error) { + vc, err := loginClient(p) + if err != nil { + return "", err + } + + path := fmt.Sprintf("auth/%s/oidc/callback", mount) + secret, err := vc.Logical().ReadWithDataWithContext(ctx, path, map[string][]string{ + "state": {state}, + "code": {code}, + "client_nonce": {clientNonce}, + }) + if err != nil { + return "", fmt.Errorf("vault oidc callback failed: %w", err) + } + if secret == nil || secret.Auth == nil || secret.Auth.ClientToken == "" { + return "", fmt.Errorf("vault oidc callback returned no client token") + } + return secret.Auth.ClientToken, nil +} diff --git a/pkg/oauth/config.go b/pkg/oauth/config.go new file mode 100644 index 0000000..629df37 --- /dev/null +++ b/pkg/oauth/config.go @@ -0,0 +1,142 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package oauth + +import ( + "fmt" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +// Config holds the OAuth Authorization Server configuration, loaded from the +// environment. OAuth is enabled only when MCPAuthSecret is set. +type Config struct { + // MCPAuthSecret is the base64url (32-byte) symmetric key used to seal all + // stateless OAuth tokens. Empty disables OAuth entirely. + MCPAuthSecret string + + // ServerURL is the public base URL advertised in OAuth metadata and used to + // build redirect targets. When empty it is derived per-request from the + // incoming Host (and X-Forwarded-Proto/Host when behind a proxy). + ServerURL string + + // Default upstream Vault address and namespace used for the login flows. + VaultAddr string + VaultNamespace string + + // Vault auth method mount points used by the login page. + LDAPMount string + UserpassMount string + OIDCMount string + OIDCRole string + + // OIDCCallbackPort is the port the server listens on to receive the OIDC + // callback from the identity provider. Vault's default OIDC role pre-registers + // http://localhost:8250/oidc/callback (the Vault CLI default), so using port + // 8250 here requires no Vault admin changes. Set VAULT_OIDC_CALLBACK_PORT=0 to + // disable the separate listener and use the main port's /vault/oidc/callback + // route instead (requires that URI to be added to the Vault OIDC role's + // allowed_redirect_uris by an admin). + OIDCCallbackPort int + + AuthCodeTTL time.Duration + AccessTokenTTL time.Duration +} + +// LoadConfigFromEnv builds a Config from environment variables. It never errors; +// call Enabled to decide whether OAuth should be wired in, and Validate to check +// the secret before constructing a Service. +func LoadConfigFromEnv() Config { + cfg := Config{ + MCPAuthSecret: strings.TrimSpace(os.Getenv("MCP_AUTH_SECRET")), + ServerURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MCP_SERVER_URL")), "/"), + VaultAddr: strings.TrimRight(strings.TrimSpace(getenv("VAULT_ADDR", "")), "/"), + VaultNamespace: strings.TrimSpace(os.Getenv("VAULT_NAMESPACE")), + LDAPMount: getenv("VAULT_AUTH_LDAP_MOUNT", "ldap"), + UserpassMount: getenv("VAULT_AUTH_USERPASS_MOUNT", "userpass"), + OIDCMount: getenv("VAULT_OIDC_MOUNT", "oidc"), + OIDCRole: strings.TrimSpace(os.Getenv("VAULT_OIDC_ROLE")), + OIDCCallbackPort: intEnv("VAULT_OIDC_CALLBACK_PORT", 8250), + AuthCodeTTL: durationEnv("MCP_AUTH_CODE_TTL", 5*time.Minute), + AccessTokenTTL: durationEnv("MCP_AUTH_ACCESS_TTL", 12*time.Hour), + } + return cfg +} + +// Enabled reports whether OAuth should be activated. +func (c Config) Enabled() bool { + return c.MCPAuthSecret != "" +} + +// Validate checks the auth secret is a usable sealer key. +func (c Config) Validate() error { + return ValidateKey(c.MCPAuthSecret) +} + +// BaseURL returns the public base URL for this server for the given request. +// It prefers an explicitly configured ServerURL, otherwise derives it from the +// request, honoring X-Forwarded-Proto / X-Forwarded-Host set by reverse proxies. +func (c Config) BaseURL(r *http.Request) string { + if c.ServerURL != "" { + return c.ServerURL + } + + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + if proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); proto != "" { + scheme = strings.Split(proto, ",")[0] + } + + host := r.Host + if fwd := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); fwd != "" { + host = strings.Split(fwd, ",")[0] + } + + return scheme + "://" + strings.TrimSpace(host) +} + +func getenv(key, fallback string) string { + if v, ok := os.LookupEnv(key); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + return fallback +} + +func durationEnv(key string, fallback time.Duration) time.Duration { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + if d, err := time.ParseDuration(v); err == nil { + return d + } + } + return fallback +} + +func intEnv(key string, fallback int) int { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return fallback +} + +// OIDCCallbackURL returns the redirect_uri to register with Vault's auth_url +// endpoint. When OIDCCallbackPort > 0 it uses the Vault CLI's pre-registered +// http://localhost:/oidc/callback URI so no Vault admin changes are needed. +// When OIDCCallbackPort == 0 the main server's /vault/oidc/callback is used +// (requires admin registration of that URI in the Vault OIDC role). +func (c Config) OIDCCallbackURL(mainServerURL string) string { + if c.OIDCCallbackPort > 0 { + return fmt.Sprintf("http://localhost:%d/oidc/callback", c.OIDCCallbackPort) + } + // Default: callback on the main server. The path /oidc/callback matches the + // URI Vault has pre-registered for localhost:8250 (the Vault CLI default), so + // running the main server on port 8250 requires no Vault admin changes. + return mainServerURL + "/oidc/callback" +} diff --git a/pkg/oauth/handlers.go b/pkg/oauth/handlers.go new file mode 100644 index 0000000..14305ca --- /dev/null +++ b/pkg/oauth/handlers.go @@ -0,0 +1,226 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package oauth + +import ( + "encoding/json" + "net/http" + "net/url" + "strings" + "time" +) + +type clientRegistrationRequest struct { + RedirectURIs []string `json:"redirect_uris"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` + GrantTypes []string `json:"grant_types,omitempty"` + ResponseTypes []string `json:"response_types,omitempty"` + ClientName string `json:"client_name,omitempty"` + Scope string `json:"scope,omitempty"` +} + +type clientRegistrationResponse struct { + ClientID string `json:"client_id"` + ClientIDIssuedAt int64 `json:"client_id_issued_at"` + RedirectURIs []string `json:"redirect_uris"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + GrantTypes []string `json:"grant_types,omitempty"` + ResponseTypes []string `json:"response_types,omitempty"` + ClientName string `json:"client_name,omitempty"` + Scope string `json:"scope,omitempty"` +} + +type tokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + Scope string `json:"scope,omitempty"` +} + +// registerClient implements RFC 7591 dynamic client registration. The returned +// client_id is itself a sealed token carrying the registration data, so no +// storage is required. +func (r *Router) registerClient(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + defer func() { _ = req.Body.Close() }() + + var in clientRegistrationRequest + if err := json.NewDecoder(req.Body).Decode(&in); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if len(in.RedirectURIs) == 0 { + http.Error(w, "redirect_uris is required", http.StatusBadRequest) + return + } + for _, ru := range in.RedirectURIs { + u, err := url.Parse(ru) + if err != nil || u.Scheme == "" || u.Host == "" { + http.Error(w, "invalid redirect_uri", http.StatusBadRequest) + return + } + } + + method := in.TokenEndpointAuthMethod + if method == "" { + method = "none" + } + + data := ClientRegistrationData{ + ClientIDIssuedAt: time.Now().UTC().Unix(), + RedirectURIs: in.RedirectURIs, + TokenEndpointAuthMethod: method, + GrantTypes: in.GrantTypes, + ResponseTypes: in.ResponseTypes, + ClientName: in.ClientName, + Scope: in.Scope, + } + + clientID, _, err := r.tokenSvc.Mint(string(TokenTypeClientID), 3650*24*time.Hour, data) + if err != nil { + http.Error(w, "failed to register client", http.StatusInternalServerError) + return + } + + writeJSON(w, clientRegistrationResponse{ + ClientID: clientID, + ClientIDIssuedAt: data.ClientIDIssuedAt, + RedirectURIs: data.RedirectURIs, + TokenEndpointAuthMethod: data.TokenEndpointAuthMethod, + GrantTypes: data.GrantTypes, + ResponseTypes: data.ResponseTypes, + ClientName: data.ClientName, + Scope: data.Scope, + }) +} + +// authorize starts the authorization-code + PKCE flow, then redirects the +// browser to the interactive Vault login page. +func (r *Router) authorize(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + q := req.URL.Query() + responseType := q.Get("response_type") + clientID := q.Get("client_id") + redirectURI := q.Get("redirect_uri") + codeChallenge := q.Get("code_challenge") + codeChallengeMethod := q.Get("code_challenge_method") + state := q.Get("state") + scope := q.Get("scope") + + if responseType != "code" { + http.Error(w, "unsupported response_type", http.StatusBadRequest) + return + } + if clientID == "" || redirectURI == "" || codeChallenge == "" { + http.Error(w, "missing required parameters", http.StatusBadRequest) + return + } + if codeChallengeMethod == "" { + codeChallengeMethod = "S256" + } + if codeChallengeMethod != "S256" { + http.Error(w, "unsupported code_challenge_method", http.StatusBadRequest) + return + } + + var reg ClientRegistrationData + if _, err := r.tokenSvc.Parse(string(TokenTypeClientID), clientID, ®); err != nil { + http.Error(w, "invalid client_id", http.StatusBadRequest) + return + } + if !contains(reg.RedirectURIs, redirectURI) { + http.Error(w, "redirect_uri not registered", http.StatusBadRequest) + return + } + + authState := AuthState{ + RedirectURI: redirectURI, + CodeChallenge: codeChallenge, + CodeChallengeMethod: codeChallengeMethod, + State: state, + Scopes: splitScopes(scope), + ClientID: clientID, + CreatedAtUnixSeconds: time.Now().UTC().Unix(), + } + encState, _, err := r.tokenSvc.Mint(string(TokenTypeAuthState), r.cfg.AuthCodeTTL, authState) + if err != nil { + http.Error(w, "failed to start auth", http.StatusInternalServerError) + return + } + + loginURL := r.cfg.BaseURL(req) + "/vault/login?" + url.Values{"auth_state": []string{encState}}.Encode() + http.Redirect(w, req, loginURL, http.StatusFound) +} + +// token exchanges an authorization code (+ PKCE verifier) for a sealed bearer +// access token carrying the Vault credentials. +func (r *Router) token(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if err := req.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + + grantType := req.FormValue("grant_type") + code := req.FormValue("code") + codeVerifier := req.FormValue("code_verifier") + clientID := req.FormValue("client_id") + redirectURI := req.FormValue("redirect_uri") + + if grantType != "authorization_code" { + http.Error(w, "unsupported grant_type", http.StatusBadRequest) + return + } + if code == "" || codeVerifier == "" || clientID == "" || redirectURI == "" { + http.Error(w, "missing required parameters", http.StatusBadRequest) + return + } + + var cd AuthCodeData + if _, err := r.tokenSvc.Parse(string(TokenTypeAuthCode), code, &cd); err != nil { + http.Error(w, "invalid code", http.StatusBadRequest) + return + } + if cd.ClientID != clientID { + http.Error(w, "client_id mismatch", http.StatusBadRequest) + return + } + if cd.RedirectURI != redirectURI { + http.Error(w, "redirect_uri mismatch", http.StatusBadRequest) + return + } + if cd.CodeChallengeMethod != "S256" || !VerifyCodeChallengeS256(codeVerifier, cd.CodeChallenge) { + http.Error(w, "invalid code_verifier", http.StatusBadRequest) + return + } + + at := AccessTokenData{ + VaultToken: cd.VaultToken, + VaultAddr: cd.VaultAddr, + VaultNamespace: cd.VaultNamespace, + CreatedAtUnixSeconds: time.Now().UTC().Unix(), + } + accessToken, meta, err := r.tokenSvc.Mint(string(TokenTypeAccessToken), r.cfg.AccessTokenTTL, at) + if err != nil { + http.Error(w, "failed to issue token", http.StatusInternalServerError) + return + } + + writeJSON(w, tokenResponse{ + AccessToken: accessToken, + TokenType: "Bearer", + ExpiresIn: int64(time.Until(meta.ExpiresAt).Seconds()), + Scope: strings.Join(cd.Scopes, " "), + }) +} diff --git a/pkg/oauth/login.go b/pkg/oauth/login.go new file mode 100644 index 0000000..a82c1f9 --- /dev/null +++ b/pkg/oauth/login.go @@ -0,0 +1,233 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package oauth + +import ( + "context" + "html/template" + "net/http" + "net/url" + "strings" + "time" + + "github.com/hashicorp/vault-mcp-server/pkg/client" +) + +var loginTemplate = template.Must(template.New("login").Parse(` + + + + + Vault MCP — Sign In + {{if .RedirectURL}}{{end}} + + + +
+

Vault MCP Server

+ + {{if .RedirectURL}} +
Authentication succeeded. Redirecting…
+

If you are not redirected, continue.

+
It is safe to close this tab — your MCP client now holds an encrypted access token. Nothing is stored server-side.
+ {{else}} +

Sign in to {{.VaultAddr}}. Your Vault token is encrypted into your MCP bearer token.

+ {{if .Error}}
{{.Error}}
{{end}} + +
+ + + + +
+ + + + +
+ +
+ + +
+ + +
+ + {{if .OIDCEnabled}} +
+
+ + +
+ {{end}} + +
Credentials/tokens are encrypted into your MCP bearer token. Treat them as sensitive.
+ {{end}} +
+ + +`)) + +type loginPageData struct { + AuthState string + VaultAddr string + Error string + RedirectURL string + OIDCEnabled bool +} + +// vaultLogin renders the login page (GET) and handles credential submission (POST). +func (r *Router) vaultLogin(w http.ResponseWriter, req *http.Request) { + switch req.Method { + case http.MethodGet: + r.renderLogin(w, req, req.URL.Query().Get("auth_state"), "") + case http.MethodPost: + r.handleLoginSubmit(w, req) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func (r *Router) renderLogin(w http.ResponseWriter, req *http.Request, authState, errMsg string) { + if authState == "" { + http.Error(w, "missing auth_state", http.StatusBadRequest) + return + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = loginTemplate.Execute(w, loginPageData{ + AuthState: authState, + VaultAddr: r.vaultAuthParams().Address, + Error: errMsg, + OIDCEnabled: true, + }) +} + +func (r *Router) handleLoginSubmit(w http.ResponseWriter, req *http.Request) { + if err := req.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + encState := req.FormValue("auth_state") + method := strings.TrimSpace(req.FormValue("method")) + + var st AuthState + if _, err := r.tokenSvc.Parse(string(TokenTypeAuthState), encState, &st); err != nil { + http.Error(w, "invalid or expired auth_state", http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(req.Context(), 20*time.Second) + defer cancel() + + params := r.vaultAuthParams() + + var vaultToken string + var err error + switch method { + case "token": + vaultToken = strings.TrimSpace(req.FormValue("token")) + if vaultToken == "" { + r.renderLogin(w, req, encState, "a Vault token is required") + return + } + err = client.LookupToken(ctx, params, vaultToken) + case "userpass", "ldap": + username := strings.TrimSpace(req.FormValue("username")) + password := req.FormValue("password") + if username == "" || password == "" { + r.renderLogin(w, req, encState, "username and password are required") + return + } + if method == "ldap" { + vaultToken, err = client.LoginLDAP(ctx, params, r.cfg.LDAPMount, username, password) + } else { + vaultToken, err = client.LoginUserpass(ctx, params, r.cfg.UserpassMount, username, password) + } + default: + r.renderLogin(w, req, encState, "unsupported authentication method") + return + } + + if err != nil { + r.logger.WithError(err).Warn("vault login failed") + r.renderLogin(w, req, encState, "authentication failed: "+truncateErr(err)) + return + } + + r.issueAuthCodeAndRedirect(w, req, st, vaultToken) +} + +// issueAuthCodeAndRedirect mints an authorization code carrying the Vault token +// and redirects the browser back to the MCP client's redirect_uri. +func (r *Router) issueAuthCodeAndRedirect(w http.ResponseWriter, req *http.Request, st AuthState, vaultToken string) { + codeData := AuthCodeData{ + VaultToken: vaultToken, + VaultAddr: r.vaultAuthParams().Address, + VaultNamespace: r.cfg.VaultNamespace, + RedirectURI: st.RedirectURI, + CodeChallenge: st.CodeChallenge, + CodeChallengeMethod: st.CodeChallengeMethod, + State: st.State, + Scopes: st.Scopes, + ClientID: st.ClientID, + CreatedAtUnixSeconds: time.Now().UTC().Unix(), + } + code, _, err := r.tokenSvc.Mint(string(TokenTypeAuthCode), r.cfg.AuthCodeTTL, codeData) + if err != nil { + http.Error(w, "failed to issue auth code", http.StatusInternalServerError) + return + } + + cb, err := url.Parse(st.RedirectURI) + if err != nil { + http.Error(w, "invalid redirect_uri", http.StatusBadRequest) + return + } + q := cb.Query() + q.Set("code", code) + if st.State != "" { + q.Set("state", st.State) + } + cb.RawQuery = q.Encode() + + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = loginTemplate.Execute(w, loginPageData{RedirectURL: cb.String()}) +} + +func truncateErr(err error) string { + s := strings.ReplaceAll(err.Error(), "\n", " ") + if len(s) > 240 { + return s[:240] + "…" + } + return s +} diff --git a/pkg/oauth/metadata.go b/pkg/oauth/metadata.go new file mode 100644 index 0000000..54135c5 --- /dev/null +++ b/pkg/oauth/metadata.go @@ -0,0 +1,88 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package oauth + +import ( + "encoding/json" + "net/http" +) + +type authServerMetadata struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + RegistrationEndpoint string `json:"registration_endpoint"` + ResponseTypesSupported []string `json:"response_types_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` +} + +type protectedResourceMetadata struct { + Resource string `json:"resource"` + AuthorizationServers []string `json:"authorization_servers"` +} + +// serverCard advertises the MCP server and its OAuth capability. +func (r *Router) serverCard(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + base := r.cfg.BaseURL(req) + writeJSON(w, map[string]any{ + "name": "com.viasat.vault-mcp", + "title": "Vault MCP Server", + "description": "Manage HashiCorp Vault secrets, mounts, and PKI via MCP tools.", + "homepage": base, + "transports": []map[string]any{ + {"type": "streamable-http", "url": base + "/mcp"}, + }, + "auth": map[string]any{ + "type": "oauth2", + "authorizationServerUrl": base, + }, + "tags": []string{"vault", "secrets", "pki", "security"}, + }) +} + +// authorizationServerMetadata implements RFC 8414 discovery. +func (r *Router) authorizationServerMetadata(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + base := r.cfg.BaseURL(req) + w.Header().Set("Access-Control-Allow-Origin", "*") + writeJSON(w, authServerMetadata{ + Issuer: base, + AuthorizationEndpoint: base + "/authorize", + TokenEndpoint: base + "/token", + RegistrationEndpoint: base + "/register", + ResponseTypesSupported: []string{"code"}, + GrantTypesSupported: []string{"authorization_code"}, + CodeChallengeMethodsSupported: []string{"S256"}, + TokenEndpointAuthMethodsSupported: []string{"none"}, + }) +} + +// protectedResourceMetadata implements RFC 9728 so MCP clients can discover the +// authorization server protecting the /mcp resource. +func (r *Router) protectedResourceMetadata(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + base := r.cfg.BaseURL(req) + w.Header().Set("Access-Control-Allow-Origin", "*") + writeJSON(w, protectedResourceMetadata{ + Resource: base + "/mcp", + AuthorizationServers: []string{base}, + }) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} diff --git a/pkg/oauth/oidc.go b/pkg/oauth/oidc.go new file mode 100644 index 0000000..32ac678 --- /dev/null +++ b/pkg/oauth/oidc.go @@ -0,0 +1,175 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package oauth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "net/http" + "net/url" + "sync" + "time" + + "github.com/hashicorp/vault-mcp-server/pkg/client" +) + +// oidcPending holds the round-trip state for an in-flight OIDC login, keyed by +// the Vault-generated `state` parameter that appears in the IdP callback URL. +// Entries are deleted on first use or when they expire. +type oidcPending struct { + SealedAuthState string + ClientNonce string + ExpiresAt time.Time +} + +// oidcStateMap stores {vault_state → oidcPending}. It is shared between the +// main server (port 8080, /vault/oidc/start) and the callback listener (port +// 8250, /oidc/callback) which run in the same process. +var oidcStateMap sync.Map + +// oidcStart initiates the Vault OIDC login. +// +// It calls Vault's auth_url endpoint using the pre-registered callback URI +// (http://localhost:8250/oidc/callback — the Vault CLI default, always allowed). +// The Vault-generated `state` param is parsed from the returned URL and stored +// in oidcStateMap so the port-8250 callback can resume the OAuth flow without +// any server-side session or cookies crossing ports. +func (r *Router) oidcStart(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if err := req.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + encState := req.FormValue("auth_state") + + var st AuthState + if _, err := r.tokenSvc.Parse(string(TokenTypeAuthState), encState, &st); err != nil { + http.Error(w, "invalid or expired auth_state", http.StatusBadRequest) + return + } + + clientNonce, err := randomNonce() + if err != nil { + http.Error(w, "failed to start oidc", http.StatusInternalServerError) + return + } + + // Use the pre-registered callback URI so no Vault admin changes are needed. + // When OIDCCallbackPort == 0, fall back to the main server's callback route + // (requires admin registration of that URI in the Vault OIDC role). + callback := r.cfg.OIDCCallbackURL(r.cfg.BaseURL(req)) + + ctx, cancel := context.WithTimeout(req.Context(), 20*time.Second) + defer cancel() + + authURL, err := client.OIDCAuthURL(ctx, r.vaultAuthParams(), r.cfg.OIDCMount, r.cfg.OIDCRole, callback, clientNonce) + if err != nil { + r.logger.WithError(err).Warn("vault oidc auth_url failed") + r.renderLogin(w, req, encState, "OIDC start failed: "+truncateErr(err)) + return + } + + // Parse Vault's generated `state` parameter out of the IdP URL so we can + // look it up when the browser returns to the callback. + u, err := url.Parse(authURL) + if err != nil || u.Query().Get("state") == "" { + r.logger.WithError(err).Warn("vault oidc auth_url missing state param") + r.renderLogin(w, req, encState, "OIDC start failed: no state in auth_url") + return + } + vaultState := u.Query().Get("state") + + oidcStateMap.Store(vaultState, oidcPending{ + SealedAuthState: encState, + ClientNonce: clientNonce, + ExpiresAt: time.Now().Add(r.cfg.AuthCodeTTL), + }) + + http.Redirect(w, req, authURL, http.StatusFound) +} + +// oidcCallback completes the OIDC flow. It is mounted on the port-8250 server +// at /oidc/callback (matching the Vault CLI's pre-registered redirect URI) as +// well as on the main server at /vault/oidc/callback as a fallback for +// deployments where the admin has registered that URI instead. +func (r *Router) oidcCallback(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + q := req.URL.Query() + vaultState := q.Get("state") + code := q.Get("code") + if vaultState == "" || code == "" { + http.Error(w, "missing state or code", http.StatusBadRequest) + return + } + + // Retrieve and consume the pending state (one-shot). + v, ok := oidcStateMap.LoadAndDelete(vaultState) + if !ok { + http.Error(w, "unknown or expired oidc session — please restart the login", http.StatusBadRequest) + return + } + pending := v.(oidcPending) + if time.Now().After(pending.ExpiresAt) { + http.Error(w, "oidc session expired — please restart the login", http.StatusBadRequest) + return + } + + var st AuthState + if _, err := r.tokenSvc.Parse(string(TokenTypeAuthState), pending.SealedAuthState, &st); err != nil { + http.Error(w, "invalid or expired auth_state", http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(req.Context(), 20*time.Second) + defer cancel() + + vaultToken, err := client.OIDCCallback(ctx, r.vaultAuthParams(), r.cfg.OIDCMount, vaultState, code, pending.ClientNonce) + if err != nil { + r.logger.WithError(err).Warn("vault oidc callback failed") + // Can't render the login page from the callback port — redirect to it. + loginURL := r.cfg.BaseURL(req) + "/vault/login?auth_state=" + + url.QueryEscape(pending.SealedAuthState) + "&error=" + + url.QueryEscape("OIDC sign-in failed: "+truncateErr(err)) + // BaseURL from port 8250 would be wrong; use the configured server URL. + if r.cfg.ServerURL != "" { + loginURL = r.cfg.ServerURL + "/vault/login?auth_state=" + + url.QueryEscape(pending.SealedAuthState) + "&error=" + + url.QueryEscape("OIDC sign-in failed: "+truncateErr(err)) + } + http.Redirect(w, req, loginURL, http.StatusFound) + return + } + + r.issueAuthCodeAndRedirect(w, req, st, vaultToken) +} + +// OIDCCallbackMux returns an http.ServeMux with only the /oidc/callback route +// registered. Mount this on a separate net/http.Server on OIDCCallbackPort so +// the browser's redirect to http://localhost:8250/oidc/callback is handled +// without requiring Vault admin registration of the main server's callback URL. +func (r *Router) OIDCCallbackMux() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("/oidc/callback", r.oidcCallback) + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok","service":"vault-oidc-callback"}`)) + }) + return mux +} + +func randomNonce() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/pkg/oauth/pkce.go b/pkg/oauth/pkce.go new file mode 100644 index 0000000..3526741 --- /dev/null +++ b/pkg/oauth/pkce.go @@ -0,0 +1,23 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package oauth + +import ( + "crypto/sha256" + "encoding/base64" + "strings" +) + +// CodeChallengeS256 computes the PKCE S256 challenge for a verifier. +func CodeChallengeS256(codeVerifier string) string { + sum := sha256.Sum256([]byte(codeVerifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +// VerifyCodeChallengeS256 reports whether codeVerifier matches expectedChallenge +// under the PKCE S256 method. +func VerifyCodeChallengeS256(codeVerifier string, expectedChallenge string) bool { + got := CodeChallengeS256(codeVerifier) + return strings.EqualFold(got, expectedChallenge) +} diff --git a/pkg/oauth/pkce_test.go b/pkg/oauth/pkce_test.go new file mode 100644 index 0000000..d9256c8 --- /dev/null +++ b/pkg/oauth/pkce_test.go @@ -0,0 +1,19 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package oauth + +import "testing" + +func TestVerifyCodeChallengeS256(t *testing.T) { + // Well-known PKCE test vector from RFC 7636 Appendix B. + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + challenge := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + + if !VerifyCodeChallengeS256(verifier, challenge) { + t.Fatalf("expected verifier to match challenge") + } + if VerifyCodeChallengeS256(verifier, "wrong-challenge") { + t.Fatalf("expected mismatch for wrong challenge") + } +} diff --git a/pkg/oauth/router.go b/pkg/oauth/router.go new file mode 100644 index 0000000..b762d22 --- /dev/null +++ b/pkg/oauth/router.go @@ -0,0 +1,150 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +// Package oauth implements a stateless OAuth 2.1 Authorization Server that lets +// MCP clients drive a browser login against an upstream HashiCorp Vault. The +// Vault token obtained during login is sealed (AES-256-GCM) into the OAuth +// bearer token; the bearer middleware unseals it on each MCP request and injects +// it into the request context. No session state is stored server-side. +package oauth + +import ( + "fmt" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/hashicorp/vault-mcp-server/pkg/client" + log "github.com/sirupsen/logrus" +) + +// Router wires the OAuth endpoints and bearer middleware. +type Router struct { + cfg Config + logger *log.Logger + tokenSvc *Service +} + +// NewRouter constructs a Router from cfg. It returns an error if the auth secret +// is not a valid sealer key. +func NewRouter(cfg Config, logger *log.Logger) (*Router, error) { + sealer, err := NewSealer(cfg.MCPAuthSecret) + if err != nil { + return nil, err + } + return &Router{ + cfg: cfg, + logger: logger, + tokenSvc: NewService(sealer, time.Now), + }, nil +} + +// Register mounts all OAuth + discovery endpoints on mux. +func (r *Router) Register(mux *http.ServeMux) { + // Discovery. + mux.HandleFunc("/.well-known/mcp/server-card.json", r.serverCard) + mux.HandleFunc("/.well-known/oauth-authorization-server", r.authorizationServerMetadata) + mux.HandleFunc("/.well-known/oauth-protected-resource", r.protectedResourceMetadata) + mux.HandleFunc("/.well-known/oauth-protected-resource/mcp", r.protectedResourceMetadata) + + // OAuth endpoints. + mux.HandleFunc("/register", r.registerClient) + mux.HandleFunc("/authorize", r.authorize) + mux.HandleFunc("/token", r.token) + + // Interactive Vault login + OIDC passthrough. + mux.HandleFunc("/vault/login", r.vaultLogin) + mux.HandleFunc("/vault/oidc/start", r.oidcStart) + // /oidc/callback is the path Vault has pre-registered for localhost:8250, + // so it must live on the main mux. /vault/oidc/callback is kept as an alias. + mux.HandleFunc("/oidc/callback", r.oidcCallback) + mux.HandleFunc("/vault/oidc/callback", r.oidcCallback) +} + +// BearerMiddleware protects the wrapped MCP handler. A valid sealed bearer token +// is unsealed and its Vault credentials injected into the request context. When +// no bearer is present it permits the developer bypass (env VAULT_TOKEN set), +// otherwise it returns 401 with a WWW-Authenticate challenge so MCP clients begin +// the OAuth flow. +func (r *Router) BearerMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + authz := req.Header.Get("Authorization") + if strings.HasPrefix(strings.ToLower(authz), "bearer ") { + token := strings.TrimSpace(authz[len("bearer "):]) + var at AccessTokenData + if _, err := r.tokenSvc.Parse(string(TokenTypeAccessToken), token, &at); err != nil { + r.challenge(w, req) + return + } + ctx := client.ContextWithVaultAuth(req.Context(), at.VaultAddr, at.VaultToken, at.VaultNamespace) + next.ServeHTTP(w, req.WithContext(ctx)) + return + } + + // Developer bypass: a VAULT_TOKEN in the environment skips browser OAuth. + if strings.TrimSpace(os.Getenv(client.VaultToken)) != "" { + next.ServeHTTP(w, req) + return + } + + r.challenge(w, req) + }) +} + +// challenge responds with a 401 and a WWW-Authenticate header pointing MCP +// clients at the protected-resource metadata to begin the OAuth flow. +func (r *Router) challenge(w http.ResponseWriter, req *http.Request) { + resourceMeta := r.cfg.BaseURL(req) + "/.well-known/oauth-protected-resource/mcp" + w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata=%q`, resourceMeta)) + http.Error(w, "authentication required", http.StatusUnauthorized) +} + +// vaultAuthParams builds the upstream Vault connection parameters from config. +func (r *Router) vaultAuthParams() client.VaultAuthParams { + addr := r.cfg.VaultAddr + if addr == "" { + addr = client.DefaultVaultAddress + } + return client.VaultAuthParams{ + Address: addr, + Namespace: r.cfg.VaultNamespace, + SkipTLSVerify: skipTLSVerifyEnv(), + } +} + +func skipTLSVerifyEnv() bool { + if v := strings.TrimSpace(os.Getenv(client.VaultSkipTLSVerify)); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + return b + } + } + return false +} + +func contains(list []string, v string) bool { + for _, s := range list { + if s == v { + return true + } + } + return false +} + +func splitScopes(scope string) []string { + fields := strings.Fields(strings.TrimSpace(scope)) + if len(fields) == 0 { + return nil + } + out := make([]string, 0, len(fields)) + seen := make(map[string]struct{}, len(fields)) + for _, f := range fields { + if _, ok := seen[f]; ok { + continue + } + seen[f] = struct{}{} + out = append(out, f) + } + return out +} diff --git a/pkg/oauth/securetoken.go b/pkg/oauth/securetoken.go new file mode 100644 index 0000000..f7cf311 --- /dev/null +++ b/pkg/oauth/securetoken.go @@ -0,0 +1,109 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package oauth + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" +) + +const ( + nonceSize = 12 + tokenV1Byte = byte(1) +) + +// ErrInvalidToken is returned when a sealed token cannot be decoded or decrypted. +var ErrInvalidToken = errors.New("invalid token") + +// Sealer encrypts and decrypts opaque tokens using AES-256-GCM. It is used to +// keep all OAuth artifacts (client_id, authorization codes, access tokens, OIDC +// round-trip state) entirely stateless: every value handed to a client is an +// authenticated ciphertext that only this server can open. +type Sealer struct { + aead cipher.AEAD +} + +// NewSealer creates an AES-256-GCM token sealer. +// +// keyB64 must be the base64url (raw, no padding) encoding of 32 random bytes. +func NewSealer(keyB64 string) (*Sealer, error) { + key, err := decodeKey(keyB64) + if err != nil { + return nil, err + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("new cipher: %w", err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("new gcm: %w", err) + } + + return &Sealer{aead: aead}, nil +} + +func decodeKey(keyB64 string) ([]byte, error) { + key, err := base64.RawURLEncoding.DecodeString(keyB64) + if err != nil { + return nil, fmt.Errorf("decode MCP_AUTH_SECRET: %w", err) + } + if len(key) != 32 { + return nil, fmt.Errorf("decode MCP_AUTH_SECRET: expected 32 bytes, got %d", len(key)) + } + return key, nil +} + +// ValidateKey reports whether keyB64 is a valid sealer key. +func ValidateKey(keyB64 string) error { + _, err := decodeKey(keyB64) + return err +} + +// Seal encrypts plaintext into a compact base64url token. +// +// Format: base64url( version(1) || nonce(12) || ciphertext+tag ). +func (s *Sealer) Seal(plaintext []byte) (string, error) { + nonce := make([]byte, nonceSize) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("read nonce: %w", err) + } + + ciphertext := s.aead.Seal(nil, nonce, plaintext, nil) + out := make([]byte, 0, 1+nonceSize+len(ciphertext)) + out = append(out, tokenV1Byte) + out = append(out, nonce...) + out = append(out, ciphertext...) + + return base64.RawURLEncoding.EncodeToString(out), nil +} + +// Open decrypts a token produced by Seal. +func (s *Sealer) Open(token string) ([]byte, error) { + raw, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return nil, fmt.Errorf("%w: base64 decode", ErrInvalidToken) + } + if len(raw) < 1+nonceSize+1 { + return nil, fmt.Errorf("%w: too short", ErrInvalidToken) + } + if raw[0] != tokenV1Byte { + return nil, fmt.Errorf("%w: unsupported version", ErrInvalidToken) + } + + nonce := raw[1 : 1+nonceSize] + ciphertext := raw[1+nonceSize:] + + plaintext, err := s.aead.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, fmt.Errorf("%w: decrypt", ErrInvalidToken) + } + return plaintext, nil +} diff --git a/pkg/oauth/securetoken_test.go b/pkg/oauth/securetoken_test.go new file mode 100644 index 0000000..a9d6265 --- /dev/null +++ b/pkg/oauth/securetoken_test.go @@ -0,0 +1,78 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package oauth + +import ( + "crypto/rand" + "encoding/base64" + "testing" +) + +func testKey(t *testing.T) string { + t.Helper() + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + t.Fatalf("rand: %v", err) + } + return base64.RawURLEncoding.EncodeToString(key) +} + +func TestSealer_RoundTrip(t *testing.T) { + s, err := NewSealer(testKey(t)) + if err != nil { + t.Fatalf("NewSealer: %v", err) + } + + plaintext := []byte("hello vault") + tok, err := s.Seal(plaintext) + if err != nil { + t.Fatalf("Seal: %v", err) + } + + got, err := s.Open(tok) + if err != nil { + t.Fatalf("Open: %v", err) + } + if string(got) != string(plaintext) { + t.Fatalf("roundtrip mismatch: got %q want %q", got, plaintext) + } +} + +func TestSealer_WrongKey(t *testing.T) { + s1, err := NewSealer(testKey(t)) + if err != nil { + t.Fatalf("NewSealer(1): %v", err) + } + s2, err := NewSealer(testKey(t)) + if err != nil { + t.Fatalf("NewSealer(2): %v", err) + } + + tok, err := s1.Seal([]byte("secret")) + if err != nil { + t.Fatalf("Seal: %v", err) + } + if _, err := s2.Open(tok); err == nil { + t.Fatalf("expected error opening with wrong key") + } +} + +func TestSealer_InvalidToken(t *testing.T) { + s, err := NewSealer(testKey(t)) + if err != nil { + t.Fatalf("NewSealer: %v", err) + } + if _, err := s.Open("not-base64!!"); err == nil { + t.Fatalf("expected error") + } +} + +func TestValidateKey(t *testing.T) { + if err := ValidateKey(testKey(t)); err != nil { + t.Fatalf("ValidateKey valid: %v", err) + } + if err := ValidateKey("short"); err == nil { + t.Fatalf("expected error for short key") + } +} diff --git a/pkg/oauth/service.go b/pkg/oauth/service.go new file mode 100644 index 0000000..a783c33 --- /dev/null +++ b/pkg/oauth/service.go @@ -0,0 +1,123 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package oauth + +import ( + "encoding/json" + "errors" + "fmt" + "time" +) + +var ( + // ErrTokenExpired is returned when a token's exp is in the past. + ErrTokenExpired = errors.New("token expired") + // ErrTokenTypeMismatch is returned when a token is parsed as the wrong type. + ErrTokenTypeMismatch = errors.New("token type mismatch") + // ErrTokenInvalid is returned when a token cannot be opened or decoded. + ErrTokenInvalid = errors.New("token invalid") +) + +// Clock returns the current time; injectable for tests. +type Clock func() time.Time + +// Service mints and parses typed, expiring, sealed tokens on top of a Sealer. +type Service struct { + sealer *Sealer + now Clock +} + +// NewService creates a token Service. If now is nil, time.Now is used. +func NewService(sealer *Sealer, now Clock) *Service { + if now == nil { + now = time.Now + } + return &Service{sealer: sealer, now: now} +} + +// Envelope is the JSON structure sealed into every token. +type Envelope struct { + Typ string `json:"typ"` + Iat int64 `json:"iat"` + Exp int64 `json:"exp"` + Data json.RawMessage `json:"data"` +} + +// Meta describes a token's issuance and expiry. +type Meta struct { + IssuedAt time.Time + ExpiresAt time.Time +} + +// Mint seals data into a typed token valid for ttl. +func (s *Service) Mint(typ string, ttl time.Duration, data any) (string, Meta, error) { + now := s.now().UTC() + exp := now.Add(ttl) + + payload, err := json.Marshal(data) + if err != nil { + return "", Meta{}, fmt.Errorf("marshal token payload: %w", err) + } + + env := Envelope{ + Typ: typ, + Iat: now.Unix(), + Exp: exp.Unix(), + Data: payload, + } + b, err := json.Marshal(env) + if err != nil { + return "", Meta{}, fmt.Errorf("marshal token envelope: %w", err) + } + tok, err := s.sealer.Seal(b) + if err != nil { + return "", Meta{}, fmt.Errorf("seal token: %w", err) + } + return tok, Meta{IssuedAt: now, ExpiresAt: exp}, nil +} + +// ParseEnvelope opens a token and validates its expiry, returning the raw envelope. +func (s *Service) ParseEnvelope(token string) (Envelope, Meta, error) { + b, err := s.sealer.Open(token) + if err != nil { + return Envelope{}, Meta{}, fmt.Errorf("%w: %v", ErrTokenInvalid, err) + } + + var env Envelope + if err := json.Unmarshal(b, &env); err != nil { + return Envelope{}, Meta{}, fmt.Errorf("%w: unmarshal", ErrTokenInvalid) + } + + now := s.now().UTC().Unix() + if now >= env.Exp { + return Envelope{}, Meta{}, ErrTokenExpired + } + + meta := Meta{ + IssuedAt: time.Unix(env.Iat, 0).UTC(), + ExpiresAt: time.Unix(env.Exp, 0).UTC(), + } + return env, meta, nil +} + +// Parse opens a token, checks its type against expectedType, and unmarshals its +// payload into out (which may be nil to only validate). +func (s *Service) Parse(expectedType string, token string, out any) (Meta, error) { + env, meta, err := s.ParseEnvelope(token) + if err != nil { + return Meta{}, err + } + + if env.Typ != expectedType { + return Meta{}, fmt.Errorf("%w: got %q want %q", ErrTokenTypeMismatch, env.Typ, expectedType) + } + + if out == nil { + return meta, nil + } + if err := json.Unmarshal(env.Data, out); err != nil { + return Meta{}, fmt.Errorf("%w: unmarshal payload", ErrTokenInvalid) + } + return meta, nil +} diff --git a/pkg/oauth/service_test.go b/pkg/oauth/service_test.go new file mode 100644 index 0000000..4eae84d --- /dev/null +++ b/pkg/oauth/service_test.go @@ -0,0 +1,72 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package oauth + +import ( + "errors" + "testing" + "time" +) + +type payload struct { + A string `json:"a"` +} + +func newTestService(t *testing.T, now Clock) *Service { + t.Helper() + sealer, err := NewSealer(testKey(t)) + if err != nil { + t.Fatalf("NewSealer: %v", err) + } + return NewService(sealer, now) +} + +func TestService_MintParse(t *testing.T) { + fixed := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + svc := newTestService(t, func() time.Time { return fixed }) + + tok, _, err := svc.Mint("t1", 10*time.Second, payload{A: "x"}) + if err != nil { + t.Fatalf("Mint: %v", err) + } + + var out payload + if _, err := svc.Parse("t1", tok, &out); err != nil { + t.Fatalf("Parse: %v", err) + } + if out.A != "x" { + t.Fatalf("data mismatch: got %q", out.A) + } +} + +func TestService_TypeMismatch(t *testing.T) { + svc := newTestService(t, time.Now) + + tok, _, err := svc.Mint("t1", 10*time.Second, payload{A: "x"}) + if err != nil { + t.Fatalf("Mint: %v", err) + } + + var out payload + if _, err := svc.Parse("t2", tok, &out); !errors.Is(err, ErrTokenTypeMismatch) { + t.Fatalf("expected ErrTokenTypeMismatch, got %v", err) + } +} + +func TestService_Expired(t *testing.T) { + start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + now := start + svc := newTestService(t, func() time.Time { return now }) + + tok, _, err := svc.Mint("t1", 1*time.Second, payload{A: "x"}) + if err != nil { + t.Fatalf("Mint: %v", err) + } + + now = start.Add(2 * time.Second) + var out payload + if _, err := svc.Parse("t1", tok, &out); !errors.Is(err, ErrTokenExpired) { + t.Fatalf("expected ErrTokenExpired, got %v", err) + } +} diff --git a/pkg/oauth/types.go b/pkg/oauth/types.go new file mode 100644 index 0000000..c38ad8e --- /dev/null +++ b/pkg/oauth/types.go @@ -0,0 +1,78 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package oauth + +// TokenType identifies the kind of payload sealed in a token. Parsing always +// asserts the expected type so a token minted for one purpose cannot be replayed +// as another. +type TokenType string + +const ( + TokenTypeClientID TokenType = "client_id" + TokenTypeAuthState TokenType = "auth_state" + TokenTypeAuthCode TokenType = "auth_code" + TokenTypeAccessToken TokenType = "access_token" + TokenTypeOIDCState TokenType = "oidc_state" +) + +// ClientRegistrationData is sealed into the OAuth client_id. It exists only to +// validate redirect URIs and echo back registration metadata. +type ClientRegistrationData struct { + ClientIDIssuedAt int64 `json:"client_id_issued_at"` + RedirectURIs []string `json:"redirect_uris"` + + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + GrantTypes []string `json:"grant_types,omitempty"` + ResponseTypes []string `json:"response_types,omitempty"` + + ClientName string `json:"client_name,omitempty"` + Scope string `json:"scope,omitempty"` +} + +// AuthState is sealed into a short-lived blob passed to the login page. It +// preserves the OAuth request across the interactive Vault login. +type AuthState struct { + RedirectURI string `json:"redirect_uri"` + CodeChallenge string `json:"code_challenge"` + CodeChallengeMethod string `json:"code_challenge_method"` + State string `json:"state,omitempty"` + Scopes []string `json:"scopes,omitempty"` + ClientID string `json:"client_id"` + CreatedAtUnixSeconds int64 `json:"created_at"` +} + +// AuthCodeData is sealed into the OAuth authorization code. It carries the Vault +// token obtained during login so /token can exchange it for an access token +// without any server-side storage. +type AuthCodeData struct { + VaultToken string `json:"vault_token"` + VaultAddr string `json:"vault_addr,omitempty"` + VaultNamespace string `json:"vault_namespace,omitempty"` + RedirectURI string `json:"redirect_uri"` + CodeChallenge string `json:"code_challenge"` + CodeChallengeMethod string `json:"code_challenge_method"` + State string `json:"state,omitempty"` + Scopes []string `json:"scopes,omitempty"` + ClientID string `json:"client_id"` + CreatedAtUnixSeconds int64 `json:"created_at"` +} + +// AccessTokenData is sealed into the OAuth bearer access token. The bearer +// middleware unseals it and injects these values into the request context. +type AccessTokenData struct { + VaultToken string `json:"vault_token"` + VaultAddr string `json:"vault_addr,omitempty"` + VaultNamespace string `json:"vault_namespace,omitempty"` + Subject string `json:"sub,omitempty"` + CreatedAtUnixSeconds int64 `json:"created_at"` +} + +// OIDCRoundTrip is sealed into the cookie set while the browser is at the +// identity provider, so the callback can resume the original OAuth request +// without server-side state. +type OIDCRoundTrip struct { + AuthState string `json:"auth_state"` + ClientNonce string `json:"client_nonce"` + RedirectURI string `json:"redirect_uri"` +} From bce33b6ca36d70b3800003f883e8344d1658139b Mon Sep 17 00:00:00 2001 From: Marcello DeSales Date: Mon, 1 Jun 2026 14:25:06 -0700 Subject: [PATCH 03/18] :whale: :new: add docker-compose.yaml --- docker-compose.yaml | 80 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 docker-compose.yaml diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..7fc499d --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,80 @@ +#### +#### Vault MCP Server — StreamableHTTP transport with optional browser OAuth. +#### +#### Quickstart (developer bypass, no browser OAuth): +#### export VAULT_ADDR=https://vault.seceng-iam.viasat.io +#### export VAULT_TOKEN=hvs.... # from `vault login` +#### docker compose up --build +#### +#### Browser OAuth (MCP server acts as its own OAuth Authorization Server): +#### export MCP_AUTH_SECRET=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=') +#### docker compose up --build +#### # MCP clients discover /.well-known/* and are directed to /vault/login to +#### # authenticate (LDAP / userpass / token / OIDC SSO) against VAULT_ADDR. +#### # OIDC SSO uses http://localhost:8250/oidc/callback — the Vault CLI's +#### # pre-registered redirect URI — so no Vault admin changes are needed. +#### +services: + vault-mcp-server: + image: vionix.docker.artifactory.viasat.com/agentic/customer-service/tools/vault-mcp-server + + platform: linux/x86_64 + build: + context: . + target: dev + + # The `dev` image defaults to `stdio`; run the StreamableHTTP transport on 8250. + command: ["./vault-mcp-server", "streamable-http", "--transport-host", "0.0.0.0", "--transport-port", "8250"] + + ports: + - "8250:8250" + + restart: unless-stopped + + volumes: + # Primes / caches the Viasat private CA bundle so TLS to vault.seceng-iam.viasat.io + # is trusted. If the file is missing it is fetched from VIASAT_IO_CACERT_URL on startup. + - ./data/certs:/viasat/certs + + environment: + # --- Transport --- + TRANSPORT_MODE: streamable-http + TRANSPORT_HOST: 0.0.0.0 + TRANSPORT_PORT: "8250" + MCP_ENDPOINT: ${MCP_ENDPOINT:-/mcp} + + # Public URL advertised in OAuth metadata and redirects. Override when fronted + # by a reverse proxy / public hostname. + MCP_SERVER_URL: ${MCP_SERVER_URL:-http://localhost:8250} + + # --- OAuth (browser login). Leave unset to disable OAuth and use VAULT_TOKEN below. --- + # 32 random bytes, base64url (no padding). Generate with: + # openssl rand -base64 32 | tr '+/' '-_' | tr -d '=' + MCP_AUTH_SECRET: ${MCP_AUTH_SECRET:-} + + # --- Upstream Vault --- + VAULT_ADDR: ${VAULT_ADDR:-https://vault.seceng-iam.viasat.io} + VAULT_NAMESPACE: ${VAULT_NAMESPACE:-} + + # Developer bypass: set VAULT_TOKEN (e.g. via .env) to skip browser OAuth entirely. + VAULT_TOKEN: ${VAULT_TOKEN:-} + + # --- TLS trust (Viasat private CA) --- + VAULT_CACERT: ${VAULT_CACERT:-/viasat/certs/viasat.io.pem} + VIASAT_IO_CACERT_FILE: ${VIASAT_IO_CACERT_FILE:-/viasat/certs/viasat.io.pem} + VIASAT_IO_CACERT_URL: ${VIASAT_IO_CACERT_URL:-https://cacerts.viasat.io/all-certs.crt} + + # --- Vault auth methods used by the login page --- + VAULT_AUTH_LDAP_MOUNT: ${VAULT_AUTH_LDAP_MOUNT:-ldap} + VAULT_AUTH_USERPASS_MOUNT: ${VAULT_AUTH_USERPASS_MOUNT:-userpass} + VAULT_OIDC_MOUNT: ${VAULT_OIDC_MOUNT:-oidc} + VAULT_OIDC_ROLE: ${VAULT_OIDC_ROLE:-} + # OIDC callback is handled by this server at /oidc/callback (port 8250 matches + # Vault's pre-registered redirect URI — no second listener needed). + VAULT_OIDC_CALLBACK_PORT: "0" + + # --- CORS (StreamableHTTP). development allows localhost origins. --- + MCP_CORS_MODE: ${MCP_CORS_MODE:-development} + + # --- Logging --- + LOG_LEVEL: ${LOG_LEVEL:-info} From 2a5648179f5cb94e5c1e25033144a206042ea25b Mon Sep 17 00:00:00 2001 From: Marcello DeSales Date: Mon, 1 Jun 2026 14:25:36 -0700 Subject: [PATCH 04/18] :books: :recycle: update README.md docs --- README.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/README.md b/README.md index 859009b..2f63674 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,16 @@ The server can be configured using environment variables: - `MCP_RATE_LIMIT_GLOBAL`: Global rate limit (format: `rps:burst`) (default: `10:20`) - `MCP_RATE_LIMIT_SESSION`: Per-session rate limit (format: `rps:burst`) (default: `5:10`) +OAuth / browser login (see [Browser OAuth](#browser-oauth-vault-login)): + +- `MCP_AUTH_SECRET`: base64url (32-byte) key that seals OAuth tokens. **Setting it enables OAuth**; unset disables it. +- `MCP_SERVER_URL`: public base URL advertised in OAuth metadata/redirects (default: derived per-request from the `Host` / `X-Forwarded-*` headers) +- `MCP_AUTH_CODE_TTL`: lifetime of authorization codes / login state (default: `5m`) +- `MCP_AUTH_ACCESS_TTL`: lifetime of bearer access tokens (default: `12h`) +- `VAULT_CACERT`: PEM CA bundle path used to verify the upstream Vault TLS cert (e.g. `/viasat/certs/viasat.io.pem`) +- `VIASAT_IO_CACERT_FILE` / `VIASAT_IO_CACERT_URL`: location and source URL of the Viasat private CA bundle; fetched on startup only when the file is missing +- `VAULT_AUTH_LDAP_MOUNT` (default `ldap`), `VAULT_AUTH_USERPASS_MOUNT` (default `userpass`), `VAULT_OIDC_MOUNT` (default `oidc`), `VAULT_OIDC_ROLE` (optional): Vault auth method mounts used by the login page + ## HTTP Mode Configuration In HTTP mode, Vault configuration can be provided through multiple methods (in order of precedence): @@ -93,9 +103,65 @@ In HTTP mode, Vault configuration can be provided through multiple methods (in o The HTTP server includes a comprehensive middleware stack: - **CORS Middleware**: Enables cross-origin requests with appropriate headers +- **Bearer (OAuth) Middleware**: When OAuth is enabled, unseals the bearer token and injects the Vault credentials into the request context - **Vault Context Middleware**: Extracts Vault configuration and adds to request context - **Logging Middleware**: Structured HTTP request logging +## Browser OAuth (Vault login) + +In HTTP mode the server can act as its own **OAuth 2.1 Authorization Server** so an +interactive MCP client (Claude, an agentic CLI, etc.) is handed a URL to authenticate +against your Vault in the browser — no token copy/paste required. The Vault token obtained +during login is encrypted (AES-256-GCM) into the OAuth bearer token; nothing is stored +server-side (the flow is fully stateless). + +OAuth is **opt-in**: it activates only when `MCP_AUTH_SECRET` is set. With it unset, the +server behaves exactly as before (Vault token via env/header — see the developer bypass below). + +Enable it: + +```bash +# 32 random bytes, base64url (no padding) +export MCP_AUTH_SECRET=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=') +export VAULT_ADDR=https://vault.seceng-iam.viasat.io +docker compose up --build +``` + +### How it works + +1. The MCP client discovers `/.well-known/oauth-protected-resource/mcp` and + `/.well-known/oauth-authorization-server`, dynamically registers (`/register`), and opens + `/authorize` (authorization code + PKCE). +2. `/authorize` redirects the browser to `/vault/login`, which offers four ways to authenticate: + - **LDAP** — `auth//login/` + - **Userpass** — `auth//login/` + - **Vault token** — paste an existing token (validated via `auth/token/lookup-self`) + - **OIDC (SSO)** — Vault `auth//oidc/auth_url`; the browser is sent to your IdP + and returns to `/vault/oidc/callback`. The callback URL is computed **dynamically from the + current host**, so it works behind any hostname/reverse proxy. +3. On success the client receives an authorization code, exchanges it at `/token`, and uses the + returned `Bearer` token on `/mcp`. An invalid/expired token yields `401` with a + `WWW-Authenticate` challenge so the client re-authenticates. + +> **OIDC note:** the dynamic callback `https:///vault/oidc/callback` must be present in the +> Vault OIDC role's `allowed_redirect_uris`. Set the role via `VAULT_OIDC_ROLE`. + +### TLS to a private Vault (the Viasat CA) + +To trust `https://vault.seceng-iam.viasat.io`, mount the Viasat private CA bundle and point +`VAULT_CACERT` at it. The `docker-compose.yaml` mounts `./data/certs:/viasat/certs` and, when the +bundle file is missing, fetches it from `VIASAT_IO_CACERT_URL` on startup. + +### Developer bypass (no browser) + +A developer running the image locally and registering it in an agentic CLI can skip OAuth +entirely by leaving `MCP_AUTH_SECRET` unset and providing a token directly, e.g. in a `.env`: + +```bash +VAULT_ADDR=https://vault.seceng-iam.viasat.io +VAULT_TOKEN=hvs.... # from `vault login` +``` + ## Integration with Visual Studio Code 1. In your project workspace root, create or open the `.vscode/mcp.json` configuration file. Alternatively, to add an MCP to your user configuration, run the `MCP: Open User Configuration` command, which opens the mcp.json file in your user profile. If the file does not exist, VS Code creates it for you. From 08b26ace9c13d32e8948514a8e09a44473128609 Mon Sep 17 00:00:00 2001 From: Marcello DeSales Date: Thu, 25 Jun 2026 13:30:53 -0700 Subject: [PATCH 05/18] :see_no_evil: add this --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 602af2a..aa7b60c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ # Test binary, built with `go test -c` *.test +data/ # Output of the go coverage tool, specifically when used with LiteIDE *.out From 8ded7a68ca6c99f067cbc0a5b096b2ab06ca94e1 Mon Sep 17 00:00:00 2001 From: Marcello DeSales Date: Fri, 26 Jun 2026 11:16:32 -0700 Subject: [PATCH 06/18] :hamster: :recycle: support oauth authentication from browser --- pkg/tools/kv/kv_test.go | 91 ++++++++++++++++++++++++++++++++++++ pkg/tools/kv/list_secrets.go | 72 ++++++++++++++-------------- pkg/tools/sys/list_mounts.go | 63 +++++++++++++++++++++++-- 3 files changed, 187 insertions(+), 39 deletions(-) diff --git a/pkg/tools/kv/kv_test.go b/pkg/tools/kv/kv_test.go index 6c81eb1..6d73147 100644 --- a/pkg/tools/kv/kv_test.go +++ b/pkg/tools/kv/kv_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "sync/atomic" "testing" "github.com/hashicorp/vault-mcp-server/pkg/client" @@ -86,3 +87,93 @@ func getResultText(result *mcp.CallToolResult) string { } return tc.Text } + +func TestListSecretsHandler_KVV1(t *testing.T) { + logger := newLogger() + var calledV2 atomic.Bool + + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/secrets/app", "/v1/secrets/app/": + jsonResponse(w, map[string]interface{}{ + "data": map[string]interface{}{ + "keys": []string{"foo", "bar/"}, + }, + }) + case "/v1/secrets/metadata/app", "/v1/secrets/metadata/app/": + calledV2.Store(true) + w.WriteHeader(http.StatusInternalServerError) + jsonResponse(w, map[string]interface{}{"errors": []string{"unexpected v2 fallback"}}) + case "/v1/sys/mounts": + w.WriteHeader(http.StatusInternalServerError) + jsonResponse(w, map[string]interface{}{"errors": []string{"unexpected sys/mounts call"}}) + default: + w.WriteHeader(http.StatusNotFound) + jsonResponse(w, map[string]interface{}{"errors": []string{"not found"}}) + } + }) + + ctx, cleanup := newTestContext(t, h) + defer cleanup() + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "list_secrets", + Arguments: map[string]interface{}{ + "mount": "secrets", + "path": "app", + }, + }, + } + + result, err := listSecretsHandler(ctx, req, logger) + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.IsError, getResultText(result)) + require.JSONEq(t, `["foo","bar/"]`, getResultText(result)) + require.False(t, calledV2.Load()) +} + +func TestListSecretsHandler_FallsBackToKVV2(t *testing.T) { + logger := newLogger() + + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/secrets/", "/v1/secrets": + w.WriteHeader(http.StatusNotFound) + jsonResponse(w, map[string]interface{}{"errors": []string{"unsupported path"}}) + case "/v1/secrets/metadata/", "/v1/secrets/metadata": + jsonResponse(w, map[string]interface{}{ + "data": map[string]interface{}{ + "keys": []string{"alpha", "bravo/"}, + }, + }) + case "/v1/sys/mounts": + // Simulate restricted Vault tokens that cannot read sys/mounts. + w.WriteHeader(http.StatusForbidden) + jsonResponse(w, map[string]interface{}{"errors": []string{"permission denied"}}) + default: + w.WriteHeader(http.StatusNotFound) + jsonResponse(w, map[string]interface{}{"errors": []string{"not found"}}) + } + }) + + ctx, cleanup := newTestContext(t, h) + defer cleanup() + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "list_secrets", + Arguments: map[string]interface{}{ + "mount": "secrets", + "path": "", + }, + }, + } + + result, err := listSecretsHandler(ctx, req, logger) + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.IsError, getResultText(result)) + require.JSONEq(t, `["alpha","bravo/"]`, getResultText(result)) +} diff --git a/pkg/tools/kv/list_secrets.go b/pkg/tools/kv/list_secrets.go index 64b07c3..ca67986 100644 --- a/pkg/tools/kv/list_secrets.go +++ b/pkg/tools/kv/list_secrets.go @@ -71,37 +71,36 @@ func listSecretsHandler(ctx context.Context, req mcp.CallToolRequest, logger *lo return mcp.NewToolResultError(fmt.Sprintf("Failed to get Vault client: %v", err)), nil } - // Construct the full path for listing - fullPath := fmt.Sprintf(mount+"/%s", path) + trimmedPath := strings.TrimPrefix(path, "/") - mounts, err := vault.Sys().ListMounts() - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to list mounts: %v", err)), nil - } + // KV v1 LIST uses: / + fullPathV1 := fmt.Sprintf("%s/%s", mount, trimmedPath) - // Check if the mount exists - if m, ok := mounts[mount+"/"]; ok { - // is it a KV v2 mount? - if m.Options["version"] == "2" { - if path == "" { - fullPath = fmt.Sprintf("%s/metadata/", mount) - } else { - fullPath = fmt.Sprintf("%s/metadata/%s", mount, strings.TrimPrefix(path, "/")) - } - } - } else { - return mcp.NewToolResultError(fmt.Sprintf("mount path '%s' does not exist. Use 'create_mount' with the type kv2 to create the mount.", mount)), nil + // KV v2 LIST uses: /metadata/ + fullPathV2 := fmt.Sprintf("%s/metadata/%s", mount, trimmedPath) + if trimmedPath == "" { + fullPathV2 = fmt.Sprintf("%s/metadata/", mount) } - // List secrets - secret, err := vault.Logical().List(fullPath) - if err != nil { - logger.WithError(err).WithFields(log.Fields{ + // List secrets (prefer KV v1 path; fall back to KV v2). + secret, errV1 := vault.Logical().List(fullPathV1) + if errV1 != nil || secret == nil { + logger.WithError(errV1).WithFields(log.Fields{ "mount": mount, "path": path, - "full_path": fullPath, - }).Error("Failed to list secrets") - return mcp.NewToolResultError(fmt.Sprintf("Failed to list secrets: %v", err)), nil + "full_path": fullPathV1, + }).Debug("KV v1 list did not return a secret; retrying as KV v2") + + secret, err = vault.Logical().List(fullPathV2) + if err != nil { + logger.WithError(err).WithFields(log.Fields{ + "mount": mount, + "path": path, + "full_path_v1": fullPathV1, + "full_path_v2": fullPathV2, + }).Error("Failed to list secrets") + return mcp.NewToolResultError(fmt.Sprintf("Failed to list secrets (KV v1 path %q: %v; KV v2 path %q: %v)", fullPathV1, errV1, fullPathV2, err)), nil + } } if secret == nil || secret.Data == nil { @@ -112,9 +111,18 @@ func listSecretsHandler(ctx context.Context, req mcp.CallToolRequest, logger *lo return mcp.NewToolResultText("[]"), nil } - // Extract keys from the response - keys, ok := secret.Data["keys"].([]interface{}) - if !ok { + // Extract keys from the response (Vault may decode into []interface{} or []string) + var secretNames []string + switch keys := secret.Data["keys"].(type) { + case []interface{}: + for _, key := range keys { + if keyStr, ok := key.(string); ok { + secretNames = append(secretNames, keyStr) + } + } + case []string: + secretNames = append(secretNames, keys...) + default: logger.WithFields(log.Fields{ "mount": mount, "path": path, @@ -122,14 +130,6 @@ func listSecretsHandler(ctx context.Context, req mcp.CallToolRequest, logger *lo return mcp.NewToolResultText("[]"), nil } - // Convert to string slice - var secretNames []string - for _, key := range keys { - if keyStr, ok := key.(string); ok { - secretNames = append(secretNames, keyStr) - } - } - // Marshal to JSON jsonData, err := json.Marshal(secretNames) if err != nil { diff --git a/pkg/tools/sys/list_mounts.go b/pkg/tools/sys/list_mounts.go index 98315ff..adcfc67 100644 --- a/pkg/tools/sys/list_mounts.go +++ b/pkg/tools/sys/list_mounts.go @@ -49,11 +49,68 @@ func listMountHandler(ctx context.Context, req mcp.CallToolRequest, logger *log. return mcp.NewToolResultError(fmt.Sprintf("Failed to get Vault client: %v", err)), nil } - // List mounts from Vault + // List mounts from Vault. Some Vault policies forbid sys/mounts; if so, fall back + // to sys/internal/ui/mounts (used by the Vault UI) when available. mounts, err := vault.Sys().ListMounts() if err != nil { - logger.WithError(err).Error("Failed to list mounts") - return mcp.NewToolResultError(fmt.Sprintf("Failed to list mounts: %v", err)), nil + logger.WithError(err).Warn("sys/mounts failed; attempting sys/internal/ui/mounts") + + secret, readErr := vault.Logical().Read("sys/internal/ui/mounts") + if readErr != nil { + logger.WithError(readErr).Error("Failed to list mounts via sys/internal/ui/mounts") + return mcp.NewToolResultError(fmt.Sprintf("Failed to list mounts: %v", err)), nil + } + if secret == nil || secret.Data == nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to list mounts: %v", err)), nil + } + + data := secret.Data + // Some Vault versions nest mount data under known keys. + if nested, ok := data["mounts"].(map[string]interface{}); ok { + data = nested + } else if nested, ok := data["data"].(map[string]interface{}); ok { + data = nested + } + // Vault UI responses may group mounts by category (e.g. "secret", "auth"). + if nested, ok := data["secret"].(map[string]interface{}); ok { + data = nested + } + + var results []*Mount + for k, raw := range data { + m, ok := raw.(map[string]interface{}) + if !ok { + continue + } + + mt := &Mount{Name: k} + if typ, ok := m["type"].(string); ok { + mt.Type = typ + } + if desc, ok := m["description"].(string); ok { + mt.Description = desc + } + if cfg, ok := m["config"].(map[string]interface{}); ok { + if v, ok := cfg["default_lease_ttl"].(float64); ok { + mt.DefaultLeaseTTL = int(v) + } + if v, ok := cfg["max_lease_ttl"].(float64); ok { + mt.MaxLeaseTTL = int(v) + } + } + + results = append(results, mt) + } + + // Marshal the struct to JSON + jsonData, err := json.Marshal(results) + if err != nil { + logger.WithError(err).Error("Failed to marshal mounts to JSON") + return mcp.NewToolResultError(fmt.Sprintf("Error marshaling JSON: %v", err)), nil + } + + logger.WithField("mount_count", len(results)).Debug("Successfully listed mounts") + return mcp.NewToolResultText(string(jsonData)), nil } var results []*Mount From e466ea9398f033582c6111612ba8b3823100d29e Mon Sep 17 00:00:00 2001 From: Marcello DeSales Date: Tue, 30 Jun 2026 10:45:15 -0700 Subject: [PATCH 07/18] :whale: :recycle: update docker compose with general settings --- docker-compose.yaml | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 7fc499d..beed23c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -16,16 +16,13 @@ #### services: vault-mcp-server: - image: vionix.docker.artifactory.viasat.com/agentic/customer-service/tools/vault-mcp-server - - platform: linux/x86_64 + image: marcellodesales/vault-mcp-server build: context: . target: dev # The `dev` image defaults to `stdio`; run the StreamableHTTP transport on 8250. command: ["./vault-mcp-server", "streamable-http", "--transport-host", "0.0.0.0", "--transport-port", "8250"] - ports: - "8250:8250" @@ -53,16 +50,16 @@ services: MCP_AUTH_SECRET: ${MCP_AUTH_SECRET:-} # --- Upstream Vault --- - VAULT_ADDR: ${VAULT_ADDR:-https://vault.seceng-iam.viasat.io} + VAULT_ADDR: ${VAULT_ADDR:-https://vault.company.com} VAULT_NAMESPACE: ${VAULT_NAMESPACE:-} # Developer bypass: set VAULT_TOKEN (e.g. via .env) to skip browser OAuth entirely. VAULT_TOKEN: ${VAULT_TOKEN:-} # --- TLS trust (Viasat private CA) --- - VAULT_CACERT: ${VAULT_CACERT:-/viasat/certs/viasat.io.pem} - VIASAT_IO_CACERT_FILE: ${VIASAT_IO_CACERT_FILE:-/viasat/certs/viasat.io.pem} - VIASAT_IO_CACERT_URL: ${VIASAT_IO_CACERT_URL:-https://cacerts.viasat.io/all-certs.crt} + VAULT_CACERT: ${VAULT_CACERT:-/company/certs/company.io.pem} + VIASAT_IO_CACERT_FILE: ${VIASAT_IO_CACERT_FILE:-/company/certs/company.io.pem} + VIASAT_IO_CACERT_URL: ${VIASAT_IO_CACERT_URL:-https://company.com/all-certs.crt} # --- Vault auth methods used by the login page --- VAULT_AUTH_LDAP_MOUNT: ${VAULT_AUTH_LDAP_MOUNT:-ldap} From e6bfb1a53802727f8864423bbde4140ac8fe05b9 Mon Sep 17 00:00:00 2001 From: Marcello DeSales Date: Thu, 2 Jul 2026 14:12:38 -0700 Subject: [PATCH 08/18] :whale: :hamster: update vault server to not fetch certs --- cmd/vault-mcp-server/main.go | 42 +++++++-- docker-compose.yaml | 13 ++- pkg/client/cabundle.go | 163 ----------------------------------- pkg/client/client.go | 2 +- 4 files changed, 44 insertions(+), 176 deletions(-) delete mode 100644 pkg/client/cabundle.go diff --git a/cmd/vault-mcp-server/main.go b/cmd/vault-mcp-server/main.go index 83b313e..2dbbb52 100644 --- a/cmd/vault-mcp-server/main.go +++ b/cmd/vault-mcp-server/main.go @@ -156,15 +156,18 @@ func httpServerInit(ctx context.Context, hcServer *server.MCPServer, logger *log // When unset, behavior is unchanged (VAULT_TOKEN via env/header — the dev bypass). var bearer func(http.Handler) http.Handler oauthCfg := oauth.LoadConfigFromEnv() + + // Require the configured private CA bundle to be present on disk before booting. + // Bootstrap (download) should be performed out-of-process (e.g. docker-compose-viasat.yaml's + // certs-puller or scripts/fetch-secrets). This applies regardless of OAuth mode. + if err := requireConfiguredCACert(logger); err != nil { + return err + } + if oauthCfg.Enabled() { if err := oauthCfg.Validate(); err != nil { return fmt.Errorf("OAuth configuration error: %w", err) } - // Bootstrap the Viasat private CA bundle so TLS to Vault is trusted. - // Logs "private ca root ready / fetched / disabled" depending on state. - if _, err := client.EnsurePrivateCARoot(ctx, logger); err != nil { - logger.WithError(err).Warn("failed to ensure Viasat private CA bundle; continuing") - } oauthRouter, err := oauth.NewRouter(oauthCfg, logger) if err != nil { return fmt.Errorf("OAuth init error: %w", err) @@ -435,3 +438,32 @@ func getEndpointPath(cmd *cobra.Command) string { return DefaultEndPointPath } + +func requireConfiguredCACert(logger *log.Logger) error { + candidates := make([]string, 0, 2) + for _, key := range []string{client.VaultCACert, client.VIASATIOCACertFile} { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + candidates = append(candidates, v) + } + } + + // No explicit CA bundle configured; rely on the system trust store. + if len(candidates) == 0 { + return nil + } + + for _, p := range candidates { + info, err := os.Stat(p) + if err == nil && !info.IsDir() && info.Size() > 0 { + if logger != nil { + logger.WithFields(log.Fields{ + "path": p, + "size_bytes": info.Size(), + }).Info("private ca root ready") + } + return nil + } + } + + return fmt.Errorf("private ca root missing: expected one of %v to exist; bootstrap it (e.g. scripts/fetch-secrets) or mount it into the container", candidates) +} diff --git a/docker-compose.yaml b/docker-compose.yaml index beed23c..6b200f5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -29,9 +29,8 @@ services: restart: unless-stopped volumes: - # Primes / caches the Viasat private CA bundle so TLS to vault.seceng-iam.viasat.io - # is trusted. If the file is missing it is fetched from VIASAT_IO_CACERT_URL on startup. - - ./data/certs:/viasat/certs + # Optional: mount a private CA bundle into the container (and set VAULT_CACERT to trust it). + - ./data/certs:/company/certs:ro environment: # --- Transport --- @@ -56,10 +55,10 @@ services: # Developer bypass: set VAULT_TOKEN (e.g. via .env) to skip browser OAuth entirely. VAULT_TOKEN: ${VAULT_TOKEN:-} - # --- TLS trust (Viasat private CA) --- - VAULT_CACERT: ${VAULT_CACERT:-/company/certs/company.io.pem} - VIASAT_IO_CACERT_FILE: ${VIASAT_IO_CACERT_FILE:-/company/certs/company.io.pem} - VIASAT_IO_CACERT_URL: ${VIASAT_IO_CACERT_URL:-https://company.com/all-certs.crt} + # --- TLS trust (private CA) --- + # If a CA bundle path is configured, the server expects it to exist on disk at startup. + VAULT_CACERT: ${VAULT_CACERT:-} + VIASAT_IO_CACERT_FILE: ${VIASAT_IO_CACERT_FILE:-} # --- Vault auth methods used by the login page --- VAULT_AUTH_LDAP_MOUNT: ${VAULT_AUTH_LDAP_MOUNT:-ldap} diff --git a/pkg/client/cabundle.go b/pkg/client/cabundle.go deleted file mode 100644 index 1d15ab7..0000000 --- a/pkg/client/cabundle.go +++ /dev/null @@ -1,163 +0,0 @@ -// Copyright IBM Corp. 2025, 2026 -// SPDX-License-Identifier: MPL-2.0 - -package client - -import ( - "context" - "crypto/x509" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "time" - - log "github.com/sirupsen/logrus" -) - -// VIASATIOCACertURL is the env var pointing at the Viasat private CA bundle to -// fetch when the local bundle file (VIASAT_IO_CACERT_FILE) is missing. -const VIASATIOCACertURL = "VIASAT_IO_CACERT_URL" - -// CABootstrapStatus describes the state of the Viasat private CA bundle on disk. -type CABootstrapStatus struct { - Enabled bool - File string - URL string - Exists bool - SizeBytes int64 - UpdatedAt string // file mtime (RFC3339), set when the file exists on disk - LastFetchedAt string // set only when the file was fetched in this process run -} - -// EnsurePrivateCARoot guarantees the Viasat private CA bundle is available on -// disk at VIASAT_IO_CACERT_FILE so TLS to vault.seceng-iam.viasat.io is trusted. -// -// If the file already exists it is reused as-is (no network call). Only when the -// file is missing is VIASAT_IO_CACERT_URL fetched. This keeps container restarts -// cheap and lets operators prime the bundle by mounting a host directory at -// /viasat/certs (e.g. via docker-compose `volumes:`). When neither the file nor -// URL is configured this is a no-op. -func EnsurePrivateCARoot(ctx context.Context, logger *log.Logger) (CABootstrapStatus, error) { - file := getEnv(VIASATIOCACertFile, "") - url := getEnv(VIASATIOCACertURL, "") - - status := CABootstrapStatus{ - Enabled: file != "" && url != "", - File: file, - URL: url, - } - statPopulate(&status) - - if !status.Enabled { - if logger != nil { - logger.Info("private ca root bootstrap disabled (VIASAT_IO_CACERT_FILE or VIASAT_IO_CACERT_URL not set)") - } - return status, nil - } - - // Bundle already on disk — reuse without a network call. - if status.Exists { - if logger != nil { - logger.WithFields(log.Fields{ - "path": status.File, - "size_bytes": status.SizeBytes, - "updated_at": status.UpdatedAt, - "refetched": false, - }).Info("private ca root ready") - } - return status, nil - } - - // Bundle missing — fetch from the configured URL. - if logger != nil { - logger.WithFields(log.Fields{ - "path": status.File, - "url": status.URL, - }).Info("private ca root missing, fetching from url") - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return status, fmt.Errorf("build viasat ca request: %w", err) - } - - httpClient := &http.Client{Timeout: 30 * time.Second} - resp, err := httpClient.Do(req) - if err != nil { - return status, fmt.Errorf("fetch viasat ca bundle: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return status, fmt.Errorf("fetch viasat ca bundle: unexpected status %s", resp.Status) - } - - pemBytes, err := io.ReadAll(resp.Body) - if err != nil { - return status, fmt.Errorf("read viasat ca bundle: %w", err) - } - - pool := x509.NewCertPool() - if ok := pool.AppendCertsFromPEM(pemBytes); !ok { - return status, fmt.Errorf("viasat ca bundle at %s did not contain PEM certificates", url) - } - - if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { - return status, fmt.Errorf("create viasat ca directory: %w", err) - } - - // Write atomically via a temp file + rename. - tmp, err := os.CreateTemp(filepath.Dir(file), "viasat-io-cacert-*.pem") - if err != nil { - return status, fmt.Errorf("create temp viasat ca file: %w", err) - } - tmpPath := tmp.Name() - cleanup := true - defer func() { - _ = tmp.Close() - if cleanup { - _ = os.Remove(tmpPath) - } - }() - - if _, err := tmp.Write(pemBytes); err != nil { - return status, fmt.Errorf("write viasat ca bundle: %w", err) - } - if err := tmp.Chmod(0o644); err != nil { - return status, fmt.Errorf("chmod viasat ca bundle: %w", err) - } - if err := tmp.Close(); err != nil { - return status, fmt.Errorf("close viasat ca bundle: %w", err) - } - if err := os.Rename(tmpPath, file); err != nil { - return status, fmt.Errorf("install viasat ca bundle: %w", err) - } - cleanup = false - - status.LastFetchedAt = time.Now().UTC().Format(time.RFC3339) - statPopulate(&status) - - if logger != nil { - logger.WithFields(log.Fields{ - "path": status.File, - "size_bytes": status.SizeBytes, - "updated_at": status.UpdatedAt, - "refetched": true, - }).Info("private ca root fetched") - } - - return status, nil -} - -func statPopulate(status *CABootstrapStatus) { - if status.File == "" { - return - } - if info, err := os.Stat(status.File); err == nil { - status.Exists = true - status.SizeBytes = info.Size() - status.UpdatedAt = info.ModTime().UTC().Format(time.RFC3339) - } -} diff --git a/pkg/client/client.go b/pkg/client/client.go index a334eac..73b450f 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -30,7 +30,7 @@ const ( // VaultCACert points at a PEM CA bundle used to verify the upstream Vault // TLS certificate. VIASATIOCACertFile is the shared Viasat private CA bundle - // used as a fallback (and primed by the CA bootstrap, see cabundle.go). + // used as a fallback (typically provisioned via an out-of-process bootstrap step). VaultCACert = "VAULT_CACERT" VIASATIOCACertFile = "VIASAT_IO_CACERT_FILE" ) From acf1970d9afafde53908cba202b452764316a1d9 Mon Sep 17 00:00:00 2001 From: Marcello DeSales Date: Thu, 2 Jul 2026 14:32:57 -0700 Subject: [PATCH 09/18] :hamster: :whale: add fetch certs script example This commit shows how to run the MCP server that performs a localhost OIDC authentication. When you setup the server, make sure that your server will point to your enterprise Vault and so you can run it locally. Then, as shown in README.md of the fetc-certs dir, the container that loads the local cert will download and store it in a private volume share where the Vault container can bootstrap and fully load. --- README.md | 7 +- docker-compose-ENTERPRISE_EXAMPLE.yaml | 102 +++++++++++++++++ scripts/fetch-certs/Dockerfile | 20 ++++ scripts/fetch-certs/README.md | 75 ++++++++++++ scripts/fetch-certs/main.go | 152 +++++++++++++++++++++++++ 5 files changed, 353 insertions(+), 3 deletions(-) create mode 100644 docker-compose-ENTERPRISE_EXAMPLE.yaml create mode 100644 scripts/fetch-certs/Dockerfile create mode 100644 scripts/fetch-certs/README.md create mode 100644 scripts/fetch-certs/main.go diff --git a/README.md b/README.md index 2f63674..d48efcb 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ OAuth / browser login (see [Browser OAuth](#browser-oauth-vault-login)): - `MCP_AUTH_CODE_TTL`: lifetime of authorization codes / login state (default: `5m`) - `MCP_AUTH_ACCESS_TTL`: lifetime of bearer access tokens (default: `12h`) - `VAULT_CACERT`: PEM CA bundle path used to verify the upstream Vault TLS cert (e.g. `/viasat/certs/viasat.io.pem`) -- `VIASAT_IO_CACERT_FILE` / `VIASAT_IO_CACERT_URL`: location and source URL of the Viasat private CA bundle; fetched on startup only when the file is missing +- `VIASAT_IO_CACERT_FILE` / `VIASAT_IO_CACERT_URL`: location and source URL of the Viasat private CA bundle; bootstrap it out-of-process (see `scripts/fetch-secrets/` or `docker-compose-viasat.yaml`) - `VAULT_AUTH_LDAP_MOUNT` (default `ldap`), `VAULT_AUTH_USERPASS_MOUNT` (default `userpass`), `VAULT_OIDC_MOUNT` (default `oidc`), `VAULT_OIDC_ROLE` (optional): Vault auth method mounts used by the login page ## HTTP Mode Configuration @@ -149,8 +149,9 @@ docker compose up --build ### TLS to a private Vault (the Viasat CA) To trust `https://vault.seceng-iam.viasat.io`, mount the Viasat private CA bundle and point -`VAULT_CACERT` at it. The `docker-compose.yaml` mounts `./data/certs:/viasat/certs` and, when the -bundle file is missing, fetches it from `VIASAT_IO_CACERT_URL` on startup. +`VAULT_CACERT` at it. Bootstrap the CA bundle out-of-process (for example via `scripts/fetch-secrets/` +or the `certs-puller` service in `docker-compose-viasat.yaml`). The Vault MCP server will fail fast +if a CA bundle path is configured but missing on disk. ### Developer bypass (no browser) diff --git a/docker-compose-ENTERPRISE_EXAMPLE.yaml b/docker-compose-ENTERPRISE_EXAMPLE.yaml new file mode 100644 index 0000000..702dbaf --- /dev/null +++ b/docker-compose-ENTERPRISE_EXAMPLE.yaml @@ -0,0 +1,102 @@ +#### +#### Vault MCP Server — StreamableHTTP transport with optional browser OAuth. +#### +#### Quickstart (developer bypass, no browser OAuth): +#### export VAULT_ADDR=https://vault.seceng-iam.enterprise.io +#### export VAULT_TOKEN=hvs.... # from `vault login` +#### docker compose -f docker-compose-ENTERPRISE_EXAMPLE.yaml up --build +#### +#### Browser OAuth (MCP server acts as its own OAuth Authorization Server): +#### export MCP_AUTH_SECRET=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=') +#### docker compose -f docker-compose-ENTERPRISE_EXAMPLE.yaml up --build +#### # MCP clients discover /.well-known/* and are directed to /vault/login to +#### # authenticate (LDAP / userpass / token / OIDC SSO) against VAULT_ADDR. +#### # OIDC SSO uses http://localhost:8250/oidc/callback — the Vault CLI's +#### # pre-registered redirect URI — so no Vault admin changes are needed. +#### +services: + certs-puller: + build: + context: . + dockerfile: scripts/fetch-secrets/Dockerfile + + # Fetches a private CA bundle into the shared volume. + restart: on-failure + + volumes: + - enterprise-certs:/enterprise/certs + + environment: + ENTERPRISE_IO_CACERT_FILE: ${ENTERPRISE_IO_CACERT_FILE:-/enterprise/certs/enterprise.io.pem} + ENTERPRISE_IO_CACERT_URL: ${ENTERPRISE_IO_CACERT_URL:-https://cacerts.enterprise.io/all-certs.crt} + + vault-mcp-server: + image: vionix.docker.artifactory.enterprise.com/agentic/customer-service/tools/vault-mcp-server + + platform: linux/x86_64 + build: + context: . + target: dev + + # The `dev` image defaults to `stdio`; run the StreamableHTTP transport on 8250. + command: ["./vault-mcp-server", "streamable-http", "--transport-host", "0.0.0.0", "--transport-port", "8250"] + + ports: + - "8250:8250" + + restart: on-failure + + depends_on: + certs-puller: + condition: service_completed_successfully + + volumes: + # Shared volume populated by certs-puller. + - enterprise-certs:/enterprise/certs:ro + + environment: + # --- Transport --- + TRANSPORT_MODE: streamable-http + TRANSPORT_HOST: 0.0.0.0 + TRANSPORT_PORT: "8250" + MCP_ENDPOINT: ${MCP_ENDPOINT:-/mcp} + + # Public URL advertised in OAuth metadata and redirects. Override when fronted + # by a reverse proxy / public hostname. + MCP_SERVER_URL: ${MCP_SERVER_URL:-http://localhost:8250} + + # --- OAuth (browser login). Leave unset to disable OAuth and use VAULT_TOKEN below. --- + # 32 random bytes, base64url (no padding). Generate with: + # openssl rand -base64 32 | tr '+/' '-_' | tr -d '=' + MCP_AUTH_SECRET: ${MCP_AUTH_SECRET:-} + + # --- Upstream Vault --- + VAULT_ADDR: ${VAULT_ADDR:-https://vault.seceng-iam.enterprise.io} + VAULT_NAMESPACE: ${VAULT_NAMESPACE:-} + + # Developer bypass: set VAULT_TOKEN (e.g. via .env) to skip browser OAuth entirely. + VAULT_TOKEN: ${VAULT_TOKEN:-} + + # --- TLS trust (private CA) --- + # The server requires the bundle to exist on disk; certs-puller is responsible + # for downloading it into /enterprise/certs. + VAULT_CACERT: ${VAULT_CACERT:-/enterprise/certs/enterprise.io.pem} + ENTERPRISE_IO_CACERT_FILE: ${ENTERPRISE_IO_CACERT_FILE:-/enterprise/certs/enterprise.io.pem} + + # --- Vault auth methods used by the login page --- + VAULT_AUTH_LDAP_MOUNT: ${VAULT_AUTH_LDAP_MOUNT:-ldap} + VAULT_AUTH_USERPASS_MOUNT: ${VAULT_AUTH_USERPASS_MOUNT:-userpass} + VAULT_OIDC_MOUNT: ${VAULT_OIDC_MOUNT:-oidc} + VAULT_OIDC_ROLE: ${VAULT_OIDC_ROLE:-} + # OIDC callback is handled by this server at /oidc/callback (port 8250 matches + # Vault's pre-registered redirect URI — no second listener needed). + VAULT_OIDC_CALLBACK_PORT: "0" + + # --- CORS (StreamableHTTP). development allows localhost origins. --- + MCP_CORS_MODE: ${MCP_CORS_MODE:-development} + + # --- Logging --- + LOG_LEVEL: ${LOG_LEVEL:-info} + +volumes: + enterprise-certs: diff --git a/scripts/fetch-certs/Dockerfile b/scripts/fetch-certs/Dockerfile new file mode 100644 index 0000000..feb08a7 --- /dev/null +++ b/scripts/fetch-certs/Dockerfile @@ -0,0 +1,20 @@ +# Copyright IBM Corp. 2026 +# SPDX-License-Identifier: MPL-2.0 + +# certbuild captures ca-certificates for HTTPS fetches. +FROM docker.mirror.hashicorp.services/alpine:3.22@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1 AS certbuild +RUN apk add --no-cache ca-certificates + +# build compiles the helper. +FROM golang:1.25.5-alpine@sha256:ac09a5f469f307e5da71e766b0bd59c9c49ea460a528cc3e6686513d64a6f1fb AS build +WORKDIR /build +COPY go.mod go.sum ./ +COPY scripts/fetch-secrets ./scripts/fetch-secrets +RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -o /out/certs-puller ./scripts/fetch-secrets + +# Run as a tiny scratch image. +FROM scratch +WORKDIR /app +COPY --from=build /out/certs-puller /app/certs-puller +COPY --from=certbuild /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +ENTRYPOINT ["/app/certs-puller"] diff --git a/scripts/fetch-certs/README.md b/scripts/fetch-certs/README.md new file mode 100644 index 0000000..1f51d0e --- /dev/null +++ b/scripts/fetch-certs/README.md @@ -0,0 +1,75 @@ +# fetch-secrets (certs-puller) +This helper downloads a private CA bundle into a file (typically a shared Docker volume) so `vault-mcp-server` can trust an upstream Vault endpoint that uses a private PKI. + +It is designed to run as a one-shot container in Compose (service name usually `certs-puller`). + +## Behavior +- If the destination file exists and is non-empty, it exits `0` (no-op). +- If the destination file is missing/empty, it downloads the bundle from the URL, validates it contains PEM certificates, writes atomically, then exits `0`. + +## Configuration +Environment variables (preferred): +- `ENTERPRISE_IO_CACERT_FILE`: destination file path to write the PEM bundle to +- `ENTERPRISE_IO_CACERT_URL`: URL to download the PEM bundle from + +Backward-compatibility (older compose files): +- `VIASAT_IO_CACERT_FILE` +- `VIASAT_IO_CACERT_URL` + +Flags (override env vars): +- `--file ` +- `--url ` +- `--timeout ` (default: `30s`) +- `--force` (re-fetch even if the destination file already exists) + +## Using the enterprise Compose template +The repository includes `docker-compose-ENTERPRISE_EXAMPLE.yaml` as a template you can copy and fill in. + +1) Copy the template: +```sh +cp docker-compose-ENTERPRISE_EXAMPLE.yaml docker-compose-enterprise.yaml +``` + +2) Edit `docker-compose-enterprise.yaml`: +- Set `VAULT_ADDR` to your upstream Vault URL. +- Set `ENTERPRISE_IO_CACERT_URL` to where your private CA bundle is hosted. +- Ensure the file written by `certs-puller` matches what `vault-mcp-server` uses: + - `ENTERPRISE_IO_CACERT_FILE` (certs-puller writes here) + - `VAULT_CACERT` (vault-mcp-server reads from here) +- Update the `vault-mcp-server` image/registry as needed for your environment. + +3) Run it: +```sh +docker compose -f docker-compose-enterprise.yaml up --build + +docker compose -f docker-compose-company.yaml up +[+] up 1/1 + ✔ Container vault-mcp-server-certs-puller-1 Recreated 0.0s +Attaching to certs-puller-1, vault-mcp-server-1 +Container vault-mcp-server-certs-puller-1 Waiting +certs-puller-1 | private ca root fetched: /enterprise/certs/company.io.pem (42728 bytes) +certs-puller-1 exited with code 0 +Container vault-mcp-server-certs-puller-1 Exited +vault-mcp-server-1 | {"level":"info","msg":"private ca root ready","path":"/enterprise/certs/company.io.pem","size_bytes":42728,"time":"2026-07-02T21:30:53.441396922Z"} +vault-mcp-server-1 | {"addr":"0.0.0.0:8250","cors_mode":"development","endpoint":"/mcp","level":"info","msg":"http server starting","time":"2026-07-02T21:30:53.446006964Z","tls":"disabled (not recommended for production)"} +vault-mcp-server-1 | {"cacert_file":"/enterprise/certs/company.io.pem","level":"info","msg":"vault connection","time":"2026-07-02T21:30:53.446043797Z","vault_addr":"https://vault.seceng-iam.company.io","vault_namespace":""} +vault-mcp-server-1 | {"access_token_ttl":"12h0m0s","ldap_mount":"ldap","level":"info","login_methods":"ldap, userpass, token, oidc","login_page":"http://0.0.0.0:8250/vault/login","msg":"oauth enabled","oidc_callback":"http://0.0.0.0:8250/oidc/callback","oidc_mount":"oidc","oidc_role":"","time":"2026-07-02T21:30:53.446242214Z","userpass_mount":"userpass"} +Gracefully Stopping... press Ctrl+C again to force +Container vault-mcp-server-vault-mcp-server-1 Stopping +vault-mcp-server-1 | {"level":"info","msg":"Shutting down StreamableHTTP server...","time":"2026-07-02T21:30:56.562125382Z"} +Container vault-mcp-server-vault-mcp-server-1 Stopped +Container vault-mcp-server-certs-puller-1 Stopping +Container vault-mcp-server-certs-puller-1 Stopped +vault-mcp-server-1 exited with code 0 +``` + +## Example logs +Your output will vary, but a successful run typically looks like: + +```text +certs-puller-1 | private ca root fetched: /enterprise/certs/enterprise.io.pem (42728 bytes) +vault-mcp-server-1 | {"level":"info","msg":"private ca root ready","path":"/enterprise/certs/enterprise.io.pem","size_bytes":42728,"time":"..."} +vault-mcp-server-1 | {"level":"info","msg":"http server starting","addr":"0.0.0.0:8250","time":"..."} +``` + +Note: `vault-mcp-server` will fail fast if a CA bundle path is configured (e.g. `VAULT_CACERT`) but the file does not exist at startup. The Compose pattern in the template uses `depends_on: condition: service_completed_successfully` to ensure certs are present before the server starts. diff --git a/scripts/fetch-certs/main.go b/scripts/fetch-certs/main.go new file mode 100644 index 0000000..61ab9ec --- /dev/null +++ b/scripts/fetch-certs/main.go @@ -0,0 +1,152 @@ +// Copyright IBM Corp. 2026 +// SPDX-License-Identifier: MPL-2.0 + +package main + +import ( + "context" + "crypto/x509" + "flag" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + envCACertFilePrimary = "ENTERPRISE_IO_CACERT_FILE" + envCACertURLPrimary = "ENTERPRISE_IO_CACERT_URL" + + // Backward-compatibility with older compose files. + envCACertFileCompat = "VIASAT_IO_CACERT_FILE" + envCACertURLCompat = "VIASAT_IO_CACERT_URL" +) + +func firstNonEmptyEnv(keys ...string) string { + for _, k := range keys { + if v := strings.TrimSpace(os.Getenv(k)); v != "" { + return v + } + } + return "" +} + +func main() { + fileDefault := firstNonEmptyEnv(envCACertFilePrimary, envCACertFileCompat) + urlDefault := firstNonEmptyEnv(envCACertURLPrimary, envCACertURLCompat) + + file := flag.String("file", fileDefault, "Destination file path for the private CA bundle PEM") + url := flag.String("url", urlDefault, "Source URL to fetch the private CA bundle PEM from when the file is missing") + timeout := flag.Duration("timeout", 30*time.Second, "HTTP timeout (e.g. 30s)") + force := flag.Bool("force", false, "Force re-fetch even if the destination file already exists") + flag.Parse() + + if strings.TrimSpace(*file) == "" { + _, _ = fmt.Fprintf( + os.Stderr, + "error: %s (or %s) or --file is required\n", + envCACertFilePrimary, + envCACertFileCompat, + ) + os.Exit(2) + } + + if !*force { + if info, err := os.Stat(*file); err == nil && !info.IsDir() && info.Size() > 0 { + _, _ = fmt.Fprintf(os.Stdout, "private ca root ready: %s (%d bytes)\n", *file, info.Size()) + return + } + } + + if strings.TrimSpace(*url) == "" { + _, _ = fmt.Fprintf( + os.Stderr, + "error: %s (or %s) or --url is required when the CA file is missing\n", + envCACertURLPrimary, + envCACertURLCompat, + ) + os.Exit(2) + } + + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, *url, nil) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error: build request: %v\n", err) + os.Exit(1) + } + + httpClient := &http.Client{Timeout: *timeout} + resp, err := httpClient.Do(req) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error: fetch CA bundle: %v\n", err) + os.Exit(1) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + _, _ = fmt.Fprintf(os.Stderr, "error: fetch CA bundle: unexpected status %s\n", resp.Status) + os.Exit(1) + } + + pemBytes, err := io.ReadAll(resp.Body) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error: read CA bundle: %v\n", err) + os.Exit(1) + } + + pool := x509.NewCertPool() + if ok := pool.AppendCertsFromPEM(pemBytes); !ok { + _, _ = fmt.Fprintf(os.Stderr, "error: CA bundle did not contain PEM certificates (%s)\n", *url) + os.Exit(1) + } + + if err := os.MkdirAll(filepath.Dir(*file), 0o755); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error: create directory: %v\n", err) + os.Exit(1) + } + + // Write atomically via a temp file + rename. + tmp, err := os.CreateTemp(filepath.Dir(*file), "private-ca-*.pem") + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error: create temp file: %v\n", err) + os.Exit(1) + } + tmpPath := tmp.Name() + cleanup := true + defer func() { + _ = tmp.Close() + if cleanup { + _ = os.Remove(tmpPath) + } + }() + + if _, err := tmp.Write(pemBytes); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error: write CA bundle: %v\n", err) + os.Exit(1) + } + if err := tmp.Chmod(0o644); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error: chmod CA bundle: %v\n", err) + os.Exit(1) + } + if err := tmp.Close(); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error: close temp file: %v\n", err) + os.Exit(1) + } + if err := os.Rename(tmpPath, *file); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error: install CA bundle: %v\n", err) + os.Exit(1) + } + cleanup = false + + if info, err := os.Stat(*file); err == nil && !info.IsDir() { + _, _ = fmt.Fprintf(os.Stdout, "private ca root fetched: %s (%d bytes)\n", *file, info.Size()) + return + } + + _, _ = fmt.Fprintf(os.Stdout, "private ca root fetched: %s\n", *file) +} From 243b61275ae0e7b25ce2215352cc736ca3c7295b Mon Sep 17 00:00:00 2001 From: Marcello DeSales Date: Thu, 2 Jul 2026 21:25:04 -0700 Subject: [PATCH 10/18] :hamister: :recycle: update OIDC support enablement --- cmd/vault-mcp-server/main.go | 130 +++++++++++++++++++---------------- e2e/cors_e2e_test.go | 56 ++++++++++----- pkg/oauth/login.go | 46 ++++--------- pkg/oauth/router.go | 26 ++++--- 4 files changed, 138 insertions(+), 120 deletions(-) diff --git a/cmd/vault-mcp-server/main.go b/cmd/vault-mcp-server/main.go index 2dbbb52..878e633 100644 --- a/cmd/vault-mcp-server/main.go +++ b/cmd/vault-mcp-server/main.go @@ -5,6 +5,8 @@ package main import ( "context" + "crypto/rand" + "encoding/base64" "errors" "fmt" stdlog "log" @@ -145,65 +147,73 @@ func httpServerInit(ctx context.Context, hcServer *server.MCPServer, logger *log // Load CORS configuration corsConfig := client.LoadCORSConfigFromEnv() - // Create a security wrapper around the streamable server - streamableServer := client.NewSecurityHandler(baseStreamableServer, corsConfig.AllowedOrigins, corsConfig.Mode, logger) + var streamableServer http.Handler = baseStreamableServer mux := http.NewServeMux() - // When MCP_AUTH_SECRET is set, the server doubles as an OAuth Authorization - // Server: MCP clients are sent through a browser login against the upstream - // Vault and the resulting Vault token is sealed into the bearer access token. - // When unset, behavior is unchanged (VAULT_TOKEN via env/header — the dev bypass). + // StreamableHTTP supports browser-based OAuth (OIDC / LDAP / userpass) to obtain + // an upstream Vault token. MCP_AUTH_SECRET is optional: when unset, a random + // secret is generated at startup and logged at INFO level. var bearer func(http.Handler) http.Handler oauthCfg := oauth.LoadConfigFromEnv() + if !oauthCfg.Enabled() { + secret, err := generateMCPAuthSecret() + if err != nil { + return fmt.Errorf("generate MCP_AUTH_SECRET: %w", err) + } + oauthCfg.MCPAuthSecret = secret + logger.WithFields(log.Fields{ + "mcp_auth_secret": secret, + "hint": "set MCP_AUTH_SECRET to persist bearer tokens across restarts", + }).Info("generated MCP_AUTH_SECRET") + } // Require the configured private CA bundle to be present on disk before booting. // Bootstrap (download) should be performed out-of-process (e.g. docker-compose-viasat.yaml's - // certs-puller or scripts/fetch-secrets). This applies regardless of OAuth mode. + // certs-puller or scripts/fetch-secrets). if err := requireConfiguredCACert(logger); err != nil { return err } - if oauthCfg.Enabled() { - if err := oauthCfg.Validate(); err != nil { - return fmt.Errorf("OAuth configuration error: %w", err) - } - oauthRouter, err := oauth.NewRouter(oauthCfg, logger) - if err != nil { - return fmt.Errorf("OAuth init error: %w", err) + if err := oauthCfg.Validate(); err != nil { + return fmt.Errorf("OAuth configuration error: %w", err) + } + oauthRouter, err := oauth.NewRouter(oauthCfg, logger) + if err != nil { + return fmt.Errorf("OAuth init error: %w", err) + } + oauthRouter.Register(mux) + bearer = oauthRouter.BearerMiddleware + + if oauthCfg.OIDCCallbackPort > 0 { + callbackServer := &http.Server{ + Addr: fmt.Sprintf(":%d", oauthCfg.OIDCCallbackPort), + Handler: oauthRouter.OIDCCallbackMux(), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, } - oauthRouter.Register(mux) - bearer = oauthRouter.BearerMiddleware - - if oauthCfg.OIDCCallbackPort > 0 { - callbackServer := &http.Server{ - Addr: fmt.Sprintf(":%d", oauthCfg.OIDCCallbackPort), - Handler: oauthRouter.OIDCCallbackMux(), - ReadHeaderTimeout: 10 * time.Second, - ReadTimeout: 30 * time.Second, - WriteTimeout: 30 * time.Second, + go func() { + if err := callbackServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.WithError(err).Warn("OIDC callback server error") } - go func() { - if err := callbackServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - logger.WithError(err).Warn("OIDC callback server error") - } - }() - go func() { - <-ctx.Done() - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _ = callbackServer.Shutdown(shutdownCtx) - }() - } + }() + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = callbackServer.Shutdown(shutdownCtx) + }() } - // Apply middleware (innermost first). The bearer middleware unseals the OAuth - // token into the context; VaultContextMiddleware then leaves those values - // intact (it only overrides when a header/env value is present). - if bearer != nil { - streamableServer = bearer(streamableServer) - } + // Apply middleware (innermost first). + // - bearer: validates OAuth bearer tokens (or allows VAULT_TOKEN from env/header) + // - VaultContext: picks up Vault settings from request/env (bearer wins when present) + // - security handler: enforces CORS and handles OPTIONS preflight + // - logging: outermost request logging + streamableServer = bearer(streamableServer) streamableServer = client.VaultContextMiddleware(logger)(streamableServer) + streamableServer = client.NewSecurityHandler(streamableServer, corsConfig.AllowedOrigins, corsConfig.Mode, logger) streamableServer = client.LoggingMiddleware(logger)(streamableServer) // Handle the /mcp endpoint with the streamable server (with security wrapper) @@ -268,23 +278,17 @@ func httpServerInit(ctx context.Context, hcServer *server.MCPServer, logger *log "cacert_file": client.EffectiveCACertFile(), }).Info("vault connection") - if oauthCfg.Enabled() { - oidcCallback := oauthCfg.OIDCCallbackURL(fmt.Sprintf("http://%s", addr)) - logger.WithFields(log.Fields{ - "login_page": fmt.Sprintf("http://%s/vault/login", addr), - "login_methods": "ldap, userpass, token, oidc", - "ldap_mount": oauthCfg.LDAPMount, - "userpass_mount": oauthCfg.UserpassMount, - "oidc_mount": oauthCfg.OIDCMount, - "oidc_role": oauthCfg.OIDCRole, - "oidc_callback": oidcCallback, - "access_token_ttl": oauthCfg.AccessTokenTTL.String(), - }).Info("oauth enabled") - } else { - logger.WithFields(log.Fields{ - "hint": "set MCP_AUTH_SECRET to enable browser login", - }).Info("oauth disabled — using VAULT_TOKEN from env/header") - } + oidcCallback := oauthCfg.OIDCCallbackURL(fmt.Sprintf("http://%s", addr)) + logger.WithFields(log.Fields{ + "login_page": fmt.Sprintf("http://%s/vault/login", addr), + "login_methods": "ldap, userpass, oidc", + "ldap_mount": oauthCfg.LDAPMount, + "userpass_mount": oauthCfg.UserpassMount, + "oidc_mount": oauthCfg.OIDCMount, + "oidc_role": oauthCfg.OIDCRole, + "oidc_callback": oidcCallback, + "access_token_ttl": oauthCfg.AccessTokenTTL.String(), + }).Info("oauth enabled") } // ── End bootstrap summary ───────────────────────────────────────────────── @@ -439,6 +443,14 @@ func getEndpointPath(cmd *cobra.Command) string { return DefaultEndPointPath } +func generateMCPAuthSecret() (string, error) { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(key), nil +} + func requireConfiguredCACert(logger *log.Logger) error { candidates := make([]string, 0, 2) for _, key := range []string{client.VaultCACert, client.VIASATIOCACertFile} { diff --git a/e2e/cors_e2e_test.go b/e2e/cors_e2e_test.go index 645262c..8730ff4 100644 --- a/e2e/cors_e2e_test.go +++ b/e2e/cors_e2e_test.go @@ -5,6 +5,7 @@ package e2e import ( "bytes" + "encoding/base64" "encoding/json" "fmt" "net/http" @@ -13,6 +14,7 @@ import ( "testing" "time" + "github.com/hashicorp/vault-mcp-server/pkg/oauth" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -44,11 +46,37 @@ type InitializeResponse struct { ID int `json:"id"` } +func testOAuthSecret() string { + key := make([]byte, 32) + return base64.RawURLEncoding.EncodeToString(key) +} + +func mintTestAccessToken(t *testing.T, secret string) string { + t.Helper() + + sealer, err := oauth.NewSealer(secret) + require.NoError(t, err) + + svc := oauth.NewService(sealer, time.Now) + tok, _, err := svc.Mint(string(oauth.TokenTypeAccessToken), 24*time.Hour, oauth.AccessTokenData{ + VaultToken: "test-token", + VaultAddr: "http://127.0.0.1:8200", + VaultNamespace: "", + CreatedAtUnixSeconds: time.Now().UTC().Unix(), + }) + require.NoError(t, err) + + return tok +} + // TestCORSE2E tests CORS validation in the MCP server using direct HTTP requests func TestCORSE2E(t *testing.T) { // Build the Docker image for our tests buildDockerImage(t) + oauthSecret := testOAuthSecret() + accessToken := mintTestAccessToken(t, oauthSecret) + // Ensure all test containers are cleaned up at the end t.Cleanup(func() { cleanupAllTestContainers(t) @@ -72,7 +100,7 @@ func TestCORSE2E(t *testing.T) { baseURL := fmt.Sprintf("http://localhost:%s", config.port) mcpURL := fmt.Sprintf("%s/mcp", baseURL) - containerID := startHTTPContainerWithCORS(t, config.port, config.mode, config.origins) + containerID := startHTTPContainerWithCORS(t, config.port, config.mode, config.origins, oauthSecret) defer func() { stopCmd := exec.Command("docker", "stop", containerID) stopCmd.Run() @@ -81,18 +109,19 @@ func TestCORSE2E(t *testing.T) { waitForCORSServer(t, baseURL) // Now run the specific CORS tests for this configuration - runCORSTests(t, mcpURL, config.mode, config.origins) + runCORSTests(t, mcpURL, config.mode, config.origins, accessToken) }) } } // startHTTPContainerWithCORS starts a Docker container with specific CORS settings -func startHTTPContainerWithCORS(t *testing.T, port, mode, origins string) string { +func startHTTPContainerWithCORS(t *testing.T, port, mode, origins, authSecret string) string { portMapping := fmt.Sprintf("%s:8080", port) cmd := exec.Command( "docker", "run", "-d", "--rm", "-e", "TRANSPORT_MODE=http", "-e", "TRANSPORT_HOST=0.0.0.0", + "-e", "MCP_AUTH_SECRET="+authSecret, "-e", "MCP_SESSION_MODE=stateful", "-e", "MCP_RATE_LIMIT_GLOBAL=50:100", "-e", fmt.Sprintf("MCP_CORS_MODE=%s", mode), @@ -131,15 +160,7 @@ func waitForCORSServer(t *testing.T, baseURL string) { } // runCORSTests executes the CORS test cases for a specific configuration -func runCORSTests(t *testing.T, mcpURL, mode, configuredOrigins string) { - // Parse the configured origins - allowedOrigins := []string{} - if configuredOrigins != "" { - for _, origin := range strings.Split(configuredOrigins, ",") { - allowedOrigins = append(allowedOrigins, strings.TrimSpace(origin)) - } - } - +func runCORSTests(t *testing.T, mcpURL, mode, configuredOrigins, accessToken string) { // Define the test case struct type type testCase struct { name string @@ -197,11 +218,11 @@ func runCORSTests(t *testing.T, mcpURL, mode, configuredOrigins string) { if tc.method != "OPTIONS" { // Only try to initialize if we expect it to succeed if tc.expectedStatus == 200 { - sessionID = initializeMCPSession(t, mcpURL, tc.origin) + sessionID = initializeMCPSession(t, mcpURL, tc.origin, accessToken) require.NotEmpty(t, sessionID, "Expected to get a session ID for allowed origin") } else { // For requests we expect to fail, just check the CORS directly - testCORSDirectly(t, mcpURL, tc.method, tc.origin, tc.expectedStatus, tc.expectCORSHeaders) + testCORSDirectly(t, mcpURL, tc.method, tc.origin, accessToken, tc.expectedStatus, tc.expectCORSHeaders) return } } @@ -225,6 +246,7 @@ func runCORSTests(t *testing.T, mcpURL, mode, configuredOrigins string) { req, _ := http.NewRequest(tc.method, mcpURL, bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) if tc.origin != "" { req.Header.Set("Origin", tc.origin) @@ -258,9 +280,10 @@ func runCORSTests(t *testing.T, mcpURL, mode, configuredOrigins string) { } // testCORSDirectly tests CORS behavior directly without trying to establish a session -func testCORSDirectly(t *testing.T, mcpURL, method, origin string, expectedStatus int, expectCORSHeaders bool) { +func testCORSDirectly(t *testing.T, mcpURL, method, origin, accessToken string, expectedStatus int, expectCORSHeaders bool) { client := &http.Client{} req, _ := http.NewRequest(method, mcpURL, nil) + req.Header.Set("Authorization", "Bearer "+accessToken) if origin != "" { req.Header.Set("Origin", origin) @@ -286,7 +309,7 @@ func testCORSDirectly(t *testing.T, mcpURL, method, origin string, expectedStatu } // initializeMCPSession initializes an MCP session and returns the session ID -func initializeMCPSession(t *testing.T, mcpURL, origin string) string { +func initializeMCPSession(t *testing.T, mcpURL, origin, accessToken string) string { // Create the initialization payload initReq := InitializeRequest{ Jsonrpc: "2.0", @@ -314,6 +337,7 @@ func initializeMCPSession(t *testing.T, mcpURL, origin string) string { require.NoError(t, err) req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) if origin != "" { req.Header.Set("Origin", origin) } diff --git a/pkg/oauth/login.go b/pkg/oauth/login.go index a82c1f9..d9c5108 100644 --- a/pkg/oauth/login.go +++ b/pkg/oauth/login.go @@ -53,10 +53,9 @@ var loginTemplate = template.Must(template.New("login").Parse(`
- -
@@ -66,11 +65,6 @@ var loginTemplate = template.Must(template.New("login").Parse(`
-
- - -
-
@@ -85,14 +79,6 @@ var loginTemplate = template.Must(template.New("login").Parse(`
Credentials/tokens are encrypted into your MCP bearer token. Treat them as sensitive.
{{end}} - `)) @@ -150,28 +136,20 @@ func (r *Router) handleLoginSubmit(w http.ResponseWriter, req *http.Request) { params := r.vaultAuthParams() + username := strings.TrimSpace(req.FormValue("username")) + password := req.FormValue("password") + if username == "" || password == "" { + r.renderLogin(w, req, encState, "username and password are required") + return + } + var vaultToken string var err error switch method { - case "token": - vaultToken = strings.TrimSpace(req.FormValue("token")) - if vaultToken == "" { - r.renderLogin(w, req, encState, "a Vault token is required") - return - } - err = client.LookupToken(ctx, params, vaultToken) - case "userpass", "ldap": - username := strings.TrimSpace(req.FormValue("username")) - password := req.FormValue("password") - if username == "" || password == "" { - r.renderLogin(w, req, encState, "username and password are required") - return - } - if method == "ldap" { - vaultToken, err = client.LoginLDAP(ctx, params, r.cfg.LDAPMount, username, password) - } else { - vaultToken, err = client.LoginUserpass(ctx, params, r.cfg.UserpassMount, username, password) - } + case "ldap": + vaultToken, err = client.LoginLDAP(ctx, params, r.cfg.LDAPMount, username, password) + case "userpass": + vaultToken, err = client.LoginUserpass(ctx, params, r.cfg.UserpassMount, username, password) default: r.renderLogin(w, req, encState, "unsupported authentication method") return diff --git a/pkg/oauth/router.go b/pkg/oauth/router.go index b762d22..5d96bb5 100644 --- a/pkg/oauth/router.go +++ b/pkg/oauth/router.go @@ -64,27 +64,31 @@ func (r *Router) Register(mux *http.ServeMux) { } // BearerMiddleware protects the wrapped MCP handler. A valid sealed bearer token -// is unsealed and its Vault credentials injected into the request context. When -// no bearer is present it permits the developer bypass (env VAULT_TOKEN set), -// otherwise it returns 401 with a WWW-Authenticate challenge so MCP clients begin -// the OAuth flow. +// is unsealed and its Vault credentials injected into the request context. +// +// If a Vault token is supplied externally (via VAULT_TOKEN env var or X-Vault-Token +// header), the request is permitted when the bearer is missing or invalid. +// +// Otherwise, when the bearer is missing or invalid it returns 401 with a +// WWW-Authenticate challenge so MCP clients begin the OAuth flow. func (r *Router) BearerMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { authz := req.Header.Get("Authorization") if strings.HasPrefix(strings.ToLower(authz), "bearer ") { token := strings.TrimSpace(authz[len("bearer "):]) var at AccessTokenData - if _, err := r.tokenSvc.Parse(string(TokenTypeAccessToken), token, &at); err != nil { - r.challenge(w, req) + if _, err := r.tokenSvc.Parse(string(TokenTypeAccessToken), token, &at); err == nil { + ctx := client.ContextWithVaultAuth(req.Context(), at.VaultAddr, at.VaultToken, at.VaultNamespace) + next.ServeHTTP(w, req.WithContext(ctx)) return } - ctx := client.ContextWithVaultAuth(req.Context(), at.VaultAddr, at.VaultToken, at.VaultNamespace) - next.ServeHTTP(w, req.WithContext(ctx)) - return + // Fall through to external token bypass. } - // Developer bypass: a VAULT_TOKEN in the environment skips browser OAuth. - if strings.TrimSpace(os.Getenv(client.VaultToken)) != "" { + // External token bypass (for clients that cannot do OAuth). + if strings.TrimSpace(req.Header.Get(client.VaultHeaderToken)) != "" || + strings.TrimSpace(req.Header.Get(client.VaultToken)) != "" || + strings.TrimSpace(os.Getenv(client.VaultToken)) != "" { next.ServeHTTP(w, req) return } From ead8de05595b5df250d0a37d17a847c06aa222c0 Mon Sep 17 00:00:00 2001 From: Marcello DeSales Date: Thu, 2 Jul 2026 21:25:42 -0700 Subject: [PATCH 11/18] :wrench: :recycle: fix certs impl from sidecard --- scripts/fetch-certs/Dockerfile | 4 ++-- scripts/fetch-certs/README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/fetch-certs/Dockerfile b/scripts/fetch-certs/Dockerfile index feb08a7..ad62758 100644 --- a/scripts/fetch-certs/Dockerfile +++ b/scripts/fetch-certs/Dockerfile @@ -9,8 +9,8 @@ RUN apk add --no-cache ca-certificates FROM golang:1.25.5-alpine@sha256:ac09a5f469f307e5da71e766b0bd59c9c49ea460a528cc3e6686513d64a6f1fb AS build WORKDIR /build COPY go.mod go.sum ./ -COPY scripts/fetch-secrets ./scripts/fetch-secrets -RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -o /out/certs-puller ./scripts/fetch-secrets +COPY scripts/fetch-certs ./scripts/fetch-certs +RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -o /out/certs-puller ./scripts/fetch-certs # Run as a tiny scratch image. FROM scratch diff --git a/scripts/fetch-certs/README.md b/scripts/fetch-certs/README.md index 1f51d0e..4748e95 100644 --- a/scripts/fetch-certs/README.md +++ b/scripts/fetch-certs/README.md @@ -53,7 +53,7 @@ Container vault-mcp-server-certs-puller-1 Exited vault-mcp-server-1 | {"level":"info","msg":"private ca root ready","path":"/enterprise/certs/company.io.pem","size_bytes":42728,"time":"2026-07-02T21:30:53.441396922Z"} vault-mcp-server-1 | {"addr":"0.0.0.0:8250","cors_mode":"development","endpoint":"/mcp","level":"info","msg":"http server starting","time":"2026-07-02T21:30:53.446006964Z","tls":"disabled (not recommended for production)"} vault-mcp-server-1 | {"cacert_file":"/enterprise/certs/company.io.pem","level":"info","msg":"vault connection","time":"2026-07-02T21:30:53.446043797Z","vault_addr":"https://vault.seceng-iam.company.io","vault_namespace":""} -vault-mcp-server-1 | {"access_token_ttl":"12h0m0s","ldap_mount":"ldap","level":"info","login_methods":"ldap, userpass, token, oidc","login_page":"http://0.0.0.0:8250/vault/login","msg":"oauth enabled","oidc_callback":"http://0.0.0.0:8250/oidc/callback","oidc_mount":"oidc","oidc_role":"","time":"2026-07-02T21:30:53.446242214Z","userpass_mount":"userpass"} +vault-mcp-server-1 | {"access_token_ttl":"12h0m0s","ldap_mount":"ldap","level":"info","login_methods":"ldap, userpass, oidc","login_page":"http://0.0.0.0:8250/vault/login","msg":"oauth enabled","oidc_callback":"http://0.0.0.0:8250/oidc/callback","oidc_mount":"oidc","oidc_role":"","time":"2026-07-02T21:30:53.446242214Z","userpass_mount":"userpass"} Gracefully Stopping... press Ctrl+C again to force Container vault-mcp-server-vault-mcp-server-1 Stopping vault-mcp-server-1 | {"level":"info","msg":"Shutting down StreamableHTTP server...","time":"2026-07-02T21:30:56.562125382Z"} From d50f6e6329331f3c79fed4761ff09167bd3fb792 Mon Sep 17 00:00:00 2001 From: Marcello DeSales Date: Thu, 2 Jul 2026 21:26:53 -0700 Subject: [PATCH 12/18] :whale: :recycle: update to make it run --- docker-compose-ENTERPRISE_EXAMPLE.yaml | 14 ++++++++------ docker-compose.yaml | 13 +++++++------ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/docker-compose-ENTERPRISE_EXAMPLE.yaml b/docker-compose-ENTERPRISE_EXAMPLE.yaml index 702dbaf..43845cd 100644 --- a/docker-compose-ENTERPRISE_EXAMPLE.yaml +++ b/docker-compose-ENTERPRISE_EXAMPLE.yaml @@ -1,24 +1,26 @@ #### #### Vault MCP Server — StreamableHTTP transport with optional browser OAuth. #### -#### Quickstart (developer bypass, no browser OAuth): +#### Quickstart (token bypass, no browser OAuth): #### export VAULT_ADDR=https://vault.seceng-iam.enterprise.io #### export VAULT_TOKEN=hvs.... # from `vault login` #### docker compose -f docker-compose-ENTERPRISE_EXAMPLE.yaml up --build #### #### Browser OAuth (MCP server acts as its own OAuth Authorization Server): -#### export MCP_AUTH_SECRET=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=') +#### # MCP_AUTH_SECRET is optional; if unset the server generates one and logs it at INFO. +#### # export MCP_AUTH_SECRET=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=') #### docker compose -f docker-compose-ENTERPRISE_EXAMPLE.yaml up --build #### # MCP clients discover /.well-known/* and are directed to /vault/login to -#### # authenticate (LDAP / userpass / token / OIDC SSO) against VAULT_ADDR. +#### # authenticate (LDAP / userpass / OIDC SSO) against VAULT_ADDR. #### # OIDC SSO uses http://localhost:8250/oidc/callback — the Vault CLI's #### # pre-registered redirect URI — so no Vault admin changes are needed. #### services: certs-puller: + image: vionix.docker.artifactory.enterprise.com/agentic/customer-service/tools/vault/certs-fetcher build: context: . - dockerfile: scripts/fetch-secrets/Dockerfile + dockerfile: scripts/fetch-certs/Dockerfile # Fetches a private CA bundle into the shared volume. restart: on-failure @@ -65,7 +67,7 @@ services: # by a reverse proxy / public hostname. MCP_SERVER_URL: ${MCP_SERVER_URL:-http://localhost:8250} - # --- OAuth (browser login). Leave unset to disable OAuth and use VAULT_TOKEN below. --- + # --- OAuth (browser login). Optional. If unset the server generates one and logs it. --- # 32 random bytes, base64url (no padding). Generate with: # openssl rand -base64 32 | tr '+/' '-_' | tr -d '=' MCP_AUTH_SECRET: ${MCP_AUTH_SECRET:-} @@ -74,7 +76,7 @@ services: VAULT_ADDR: ${VAULT_ADDR:-https://vault.seceng-iam.enterprise.io} VAULT_NAMESPACE: ${VAULT_NAMESPACE:-} - # Developer bypass: set VAULT_TOKEN (e.g. via .env) to skip browser OAuth entirely. + # Token bypass: set VAULT_TOKEN (e.g. via .env) to skip browser OAuth. VAULT_TOKEN: ${VAULT_TOKEN:-} # --- TLS trust (private CA) --- diff --git a/docker-compose.yaml b/docker-compose.yaml index 6b200f5..76435e0 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,16 +1,17 @@ #### #### Vault MCP Server — StreamableHTTP transport with optional browser OAuth. #### -#### Quickstart (developer bypass, no browser OAuth): -#### export VAULT_ADDR=https://vault.seceng-iam.viasat.io +#### Quickstart (token bypass, no browser OAuth): +#### export VAULT_ADDR=https://vault.company.com #### export VAULT_TOKEN=hvs.... # from `vault login` #### docker compose up --build #### #### Browser OAuth (MCP server acts as its own OAuth Authorization Server): -#### export MCP_AUTH_SECRET=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=') +#### # MCP_AUTH_SECRET is optional; if unset the server generates one and logs it at INFO. +#### # export MCP_AUTH_SECRET=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=') #### docker compose up --build #### # MCP clients discover /.well-known/* and are directed to /vault/login to -#### # authenticate (LDAP / userpass / token / OIDC SSO) against VAULT_ADDR. +#### # authenticate (LDAP / userpass / OIDC SSO) against VAULT_ADDR. #### # OIDC SSO uses http://localhost:8250/oidc/callback — the Vault CLI's #### # pre-registered redirect URI — so no Vault admin changes are needed. #### @@ -43,7 +44,7 @@ services: # by a reverse proxy / public hostname. MCP_SERVER_URL: ${MCP_SERVER_URL:-http://localhost:8250} - # --- OAuth (browser login). Leave unset to disable OAuth and use VAULT_TOKEN below. --- + # --- OAuth (browser login). Optional. If unset the server generates one and logs it. --- # 32 random bytes, base64url (no padding). Generate with: # openssl rand -base64 32 | tr '+/' '-_' | tr -d '=' MCP_AUTH_SECRET: ${MCP_AUTH_SECRET:-} @@ -52,7 +53,7 @@ services: VAULT_ADDR: ${VAULT_ADDR:-https://vault.company.com} VAULT_NAMESPACE: ${VAULT_NAMESPACE:-} - # Developer bypass: set VAULT_TOKEN (e.g. via .env) to skip browser OAuth entirely. + # Token bypass: set VAULT_TOKEN (e.g. via .env) to skip browser OAuth. VAULT_TOKEN: ${VAULT_TOKEN:-} # --- TLS trust (private CA) --- From c370cf733a27c4bcbcaa22fb0df56bd4087fa6ac Mon Sep 17 00:00:00 2001 From: Marcello DeSales Date: Thu, 2 Jul 2026 21:27:11 -0700 Subject: [PATCH 13/18] :books: README: update with oidc localhost support --- README.md | 78 +++++++++++++++++++++++++------------------------------ 1 file changed, 35 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index d48efcb..061e43e 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ and other MCP clients. The server can be configured using environment variables: - `VAULT_ADDR`: Vault server address (default: `http://127.0.0.1:8200`) -- `VAULT_TOKEN`: Vault authentication token (required) +- `VAULT_TOKEN`: Vault authentication token (required in stdio mode; optional in HTTP mode — if set it bypasses browser OAuth and is used for requests) - `VAULT_NAMESPACE`: Vault namespace (optional) - `TRANSPORT_MODE`: Set to `http` to enable HTTP mode - `TRANSPORT_HOST`: Host to bind to for HTTP mode (default: `127.0.0.1`) @@ -82,7 +82,7 @@ The server can be configured using environment variables: OAuth / browser login (see [Browser OAuth](#browser-oauth-vault-login)): -- `MCP_AUTH_SECRET`: base64url (32-byte) key that seals OAuth tokens. **Setting it enables OAuth**; unset disables it. +- `MCP_AUTH_SECRET`: base64url (32-byte) key that seals OAuth tokens. Optional; if unset the server generates one at startup and logs it at INFO. Set it to keep bearer tokens valid across restarts. - `MCP_SERVER_URL`: public base URL advertised in OAuth metadata/redirects (default: derived per-request from the `Host` / `X-Forwarded-*` headers) - `MCP_AUTH_CODE_TTL`: lifetime of authorization codes / login state (default: `5m`) - `MCP_AUTH_ACCESS_TTL`: lifetime of bearer access tokens (default: `12h`) @@ -92,18 +92,21 @@ OAuth / browser login (see [Browser OAuth](#browser-oauth-vault-login)): ## HTTP Mode Configuration -In HTTP mode, Vault configuration can be provided through multiple methods (in order of precedence): +In HTTP mode, the `/mcp` endpoint always requires authentication. You can satisfy this requirement in one of two ways: -- **HTTP Query**: `VAULT_ADDR` -- **HTTP Headers**: `VAULT_ADDR`, `X-Vault-Token`, and `X-Vault-Namespace` -- **Environment Variables**: Standard `VAULT_ADDR`, `VAULT_TOKEN`, and `VAULT_NAMESPACE` env vars +- **Browser OAuth** (recommended for interactive MCP clients): the client is redirected to `/vault/login` and then calls `/mcp` with `Authorization: Bearer ` minted by this server. +- **Token bypass** (for clients that cannot do OAuth): provide a Vault token externally via `VAULT_TOKEN` (env) or the `X-Vault-Token` request header. + +Bearer tokens are sealed with `MCP_AUTH_SECRET`. If you let the server auto-generate it on each start, previously issued bearer tokens stop working after restart (clients will re-authenticate). + +Upstream Vault connection details used for the login flow are configured via environment variables (`VAULT_ADDR`, `VAULT_NAMESPACE`, `VAULT_CACERT`, etc.). ### Middleware Stack The HTTP server includes a comprehensive middleware stack: - **CORS Middleware**: Enables cross-origin requests with appropriate headers -- **Bearer (OAuth) Middleware**: When OAuth is enabled, unseals the bearer token and injects the Vault credentials into the request context +- **Bearer (OAuth) Middleware**: Unseals the bearer token and injects the Vault credentials into the request context (or bypasses OAuth when a Vault token is supplied externally) - **Vault Context Middleware**: Extracts Vault configuration and adds to request context - **Logging Middleware**: Structured HTTP request logging @@ -115,15 +118,26 @@ against your Vault in the browser — no token copy/paste required. The Vault to during login is encrypted (AES-256-GCM) into the OAuth bearer token; nothing is stored server-side (the flow is fully stateless). -OAuth is **opt-in**: it activates only when `MCP_AUTH_SECRET` is set. With it unset, the -server behaves exactly as before (Vault token via env/header — see the developer bypass below). +`MCP_AUTH_SECRET` seals the issued bearer tokens. It is optional; if unset the server generates one at +startup and logs it at INFO (set it to keep bearer tokens valid across restarts). -Enable it: +If you cannot do OAuth, you can bypass the browser flow by providing a Vault token externally +(`VAULT_TOKEN` env var or `X-Vault-Token` request header). + +Token bypass (no browser OAuth): + +```bash +export VAULT_ADDR=https://vault.seceng-iam.viasat.io +export VAULT_TOKEN=hvs.... +docker compose up --build +``` + +Browser OAuth: ```bash -# 32 random bytes, base64url (no padding) -export MCP_AUTH_SECRET=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=') export VAULT_ADDR=https://vault.seceng-iam.viasat.io +# Optional: set to keep bearer tokens valid across restarts +export MCP_AUTH_SECRET=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=') docker compose up --build ``` @@ -132,10 +146,9 @@ docker compose up --build 1. The MCP client discovers `/.well-known/oauth-protected-resource/mcp` and `/.well-known/oauth-authorization-server`, dynamically registers (`/register`), and opens `/authorize` (authorization code + PKCE). -2. `/authorize` redirects the browser to `/vault/login`, which offers four ways to authenticate: +2. `/authorize` redirects the browser to `/vault/login`, which offers three ways to authenticate: - **LDAP** — `auth//login/` - **Userpass** — `auth//login/` - - **Vault token** — paste an existing token (validated via `auth/token/lookup-self`) - **OIDC (SSO)** — Vault `auth//oidc/auth_url`; the browser is sent to your IdP and returns to `/vault/oidc/callback`. The callback URL is computed **dynamically from the current host**, so it works behind any hostname/reverse proxy. @@ -153,20 +166,13 @@ To trust `https://vault.seceng-iam.viasat.io`, mount the Viasat private CA bundl or the `certs-puller` service in `docker-compose-viasat.yaml`). The Vault MCP server will fail fast if a CA bundle path is configured but missing on disk. -### Developer bypass (no browser) - -A developer running the image locally and registering it in an agentic CLI can skip OAuth -entirely by leaving `MCP_AUTH_SECRET` unset and providing a token directly, e.g. in a `.env`: - -```bash -VAULT_ADDR=https://vault.seceng-iam.viasat.io -VAULT_TOKEN=hvs.... # from `vault login` -``` ## Integration with Visual Studio Code 1. In your project workspace root, create or open the `.vscode/mcp.json` configuration file. Alternatively, to add an MCP to your user configuration, run the `MCP: Open User Configuration` command, which opens the mcp.json file in your user profile. If the file does not exist, VS Code creates it for you. +Streamable HTTP mode supports browser OAuth; OAuth-capable MCP clients will be directed to `/vault/login` to authenticate. If you run the server with `VAULT_TOKEN`, clients can skip OAuth. + @@ -174,27 +180,9 @@ VAULT_TOKEN=hvs.... # from `vault login` ```json { - "inputs": [ - { - "type": "promptString", - "id": "vault_token", - "description": "Vault Token", - "password": true - }, - { - "type": "promptString", - "id": "vault_namespace", - "description": "Vault Namespace (optional)", - "password": false - } - ], "servers": { "vault-mcp-server": { - "url": "http://localhost:8080/mcp?VAULT_ADDR=http://127.0.0.1:8200", - "headers": { - "X-Vault-Token": "${input:vault_token}", - "X-Vault-Namespace": "${input:vault_namespace}" - } + "url": "http://localhost:8080/mcp" } } } @@ -301,7 +289,11 @@ docker logs vault-dev Run the Vault MCP server: ```bash -docker run --network=mcp -p 8080:8080 -e VAULT_ADDR='http://vault-dev:8200' -e VAULT_TOKEN='' -e TRANSPORT_MODE='http' vault-mcp-server:dev +# Option A: token bypass (no browser OAuth) +docker run --network=mcp -p 8080:8080 -e VAULT_ADDR='http://vault-dev:8200' -e VAULT_TOKEN='hvs....' -e TRANSPORT_MODE='http' vault-mcp-server:dev + +# Option B: browser OAuth (MCP_AUTH_SECRET optional) +docker run --network=mcp -p 8080:8080 -e VAULT_ADDR='http://vault-dev:8200' -e MCP_AUTH_SECRET="$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')" -e TRANSPORT_MODE='http' vault-mcp-server:dev ``` ## Available Tools From 3f8b784ce6b0a9753268b7a58493bc783c7916e1 Mon Sep 17 00:00:00 2001 From: Marcello DeSales Date: Fri, 3 Jul 2026 00:19:38 -0700 Subject: [PATCH 14/18] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 061e43e..3fab896 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ docker compose up --build ### TLS to a private Vault (the Viasat CA) To trust `https://vault.seceng-iam.viasat.io`, mount the Viasat private CA bundle and point -`VAULT_CACERT` at it. Bootstrap the CA bundle out-of-process (for example via `scripts/fetch-secrets/` +`VAULT_CACERT` at it. Bootstrap the CA bundle out-of-process (for example via `scripts/fetch-certs/` or the `certs-puller` service in `docker-compose-viasat.yaml`). The Vault MCP server will fail fast if a CA bundle path is configured but missing on disk. From 5bb931a8f3ad84b93fcd78ce414488ef13cbbab2 Mon Sep 17 00:00:00 2001 From: Marcello DeSales Date: Tue, 7 Jul 2026 09:53:34 -0700 Subject: [PATCH 15/18] :hamster: :recycle: add client renewal, error handling --- pkg/client/client.go | 9 ++- pkg/client/renew.go | 139 ++++++++++++++++++++++++++++++++++++++++++ pkg/oauth/handlers.go | 23 ++++++- pkg/oauth/login.go | 37 ++++++++++- pkg/oauth/oidc.go | 36 ++++++++++- 5 files changed, 239 insertions(+), 5 deletions(-) create mode 100644 pkg/client/renew.go diff --git a/pkg/client/client.go b/pkg/client/client.go index 73b450f..b3e32dc 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -143,8 +143,10 @@ func GetVaultClient(sessionId string) *api.Client { return nil } -// DeleteVaultClient removes the Vault client for the given session +// DeleteVaultClient removes the Vault client for the given session and stops +// any background token renewal loop. func DeleteVaultClient(sessionId string) { + StopTokenRenewal(sessionId) activeClients.Delete(sessionId) } @@ -232,6 +234,11 @@ func CreateVaultClientForSession(ctx context.Context, session server.ClientSessi "vault_addr": vaultAddress, }).Info("Created Vault client for session") + // Keep the Vault token alive for the duration of the working session. Vault + // token renewal does not change the token string — it only extends the TTL + // on Vault's side — so the existing MCP bearer token continues to work. + StartTokenRenewal(session.SessionID(), newClient, logger) + return newClient, nil } diff --git a/pkg/client/renew.go b/pkg/client/renew.go new file mode 100644 index 0000000..c98abb7 --- /dev/null +++ b/pkg/client/renew.go @@ -0,0 +1,139 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package client + +import ( + "context" + "encoding/json" + "sync" + "time" + + "github.com/hashicorp/vault/api" + log "github.com/sirupsen/logrus" +) + +// activeRenewers stores context.CancelFunc values keyed by session ID so the +// background renewal goroutine can be stopped when a session ends. +var activeRenewers sync.Map + +// StartTokenRenewal begins a background loop that renews the Vault token for +// sessionID before it expires. It is a no-op for non-renewable tokens (batch +// tokens, tokens that have already hit their max TTL, etc.). +func StartTokenRenewal(sessionID string, vc *api.Client, logger *log.Logger) { + ttl, renewable := lookupTokenTTL(context.Background(), vc) + if !renewable || ttl <= 0 { + logger.WithField("session_id", sessionID).Debug("vault token is not renewable — skipping auto-renewal") + return + } + + ctx, cancel := context.WithCancel(context.Background()) + activeRenewers.Store(sessionID, cancel) + + go func() { + defer activeRenewers.Delete(sessionID) + + interval := renewalInterval(ttl) + ticker := time.NewTicker(interval) + defer ticker.Stop() + + logger.WithFields(log.Fields{ + "session_id": sessionID, + "token_ttl": ttl, + "renewal_interval": interval, + }).Info("vault token auto-renewal started") + + for { + select { + case <-ctx.Done(): + logger.WithField("session_id", sessionID).Debug("vault token renewal loop cancelled") + return + case <-ticker.C: + secret, err := vc.Auth().Token().RenewSelfWithContext(ctx, int(ttl.Seconds())) + if err != nil { + logger.WithField("session_id", sessionID).WithError(err).Warn( + "vault token renewal failed — token will expire at its current TTL; " + + "re-authenticate when the session is rejected") + return + } + if secret == nil || secret.Auth == nil { + logger.WithField("session_id", sessionID).Warn( + "vault token renewal returned no auth data — stopping renewal loop") + return + } + + newTTL := time.Duration(secret.Auth.LeaseDuration) * time.Second + if newTTL > 0 { + ttl = newTTL + } + + newInterval := renewalInterval(ttl) + logger.WithFields(log.Fields{ + "session_id": sessionID, + "new_ttl_s": secret.Auth.LeaseDuration, + "next_renew": newInterval, + }).Debug("vault token renewed successfully") + + if newInterval != interval { + interval = newInterval + ticker.Reset(interval) + } + } + } + }() +} + +// StopTokenRenewal cancels the background renewal goroutine for sessionID. +// It is safe to call even if no renewal is running. +func StopTokenRenewal(sessionID string) { + if v, ok := activeRenewers.LoadAndDelete(sessionID); ok { + v.(context.CancelFunc)() + } +} + +// renewalInterval returns a safe renewal interval for the given remaining TTL. +// It targets half the TTL, clamped to [1 minute, 30 minutes] to avoid both +// too-frequent calls and last-minute renewal attempts. +func renewalInterval(ttl time.Duration) time.Duration { + d := ttl / 2 + if d < time.Minute { + d = time.Minute + } + if d > 30*time.Minute { + d = 30 * time.Minute + } + return d +} + +// lookupTokenTTL queries Vault for the current token's remaining TTL and +// renewability. Returns (0, false) when the lookup fails or the token is not +// renewable (e.g. batch tokens produced by OIDC flows). +func lookupTokenTTL(ctx context.Context, vc *api.Client) (time.Duration, bool) { + secret, err := vc.Auth().Token().LookupSelfWithContext(ctx) + if err != nil || secret == nil || secret.Data == nil { + return 0, false + } + + renewable, _ := secret.Data["renewable"].(bool) + if !renewable { + return 0, false + } + + ttl := jsonNumToDuration(secret.Data["ttl"]) + return ttl, ttl > 0 +} + +// jsonNumToDuration converts a Vault API response value (json.Number or float64) +// to a time.Duration in seconds. Vault's Go SDK uses UseNumber() so numeric +// fields in Data are always json.Number. +func jsonNumToDuration(v interface{}) time.Duration { + switch n := v.(type) { + case json.Number: + if i, err := n.Int64(); err == nil { + return time.Duration(i) * time.Second + } + case float64: + return time.Duration(int64(n)) * time.Second + } + return 0 +} diff --git a/pkg/oauth/handlers.go b/pkg/oauth/handlers.go index 14305ca..bedaf82 100644 --- a/pkg/oauth/handlers.go +++ b/pkg/oauth/handlers.go @@ -133,8 +133,27 @@ func (r *Router) authorize(w http.ResponseWriter, req *http.Request) { var reg ClientRegistrationData if _, err := r.tokenSvc.Parse(string(TokenTypeClientID), clientID, ®); err != nil { - http.Error(w, "invalid client_id", http.StatusBadRequest) - return + // The client_id is a sealed token minted with the server's current + // MCP_AUTH_SECRET. If decryption fails the server most likely restarted + // (generating a new secret), invalidating the stored registration. + // Rather than surfacing an error the user cannot act on, re-register the + // client on-the-fly using the redirect_uri already present in the request + // so the OIDC flow continues uninterrupted. + if redirectURI == "" { + r.renderCallbackError(w, "MCP client registration is no longer valid and no redirect_uri was provided — please reconnect your MCP client.") + return + } + u, parseErr := url.Parse(redirectURI) + if parseErr != nil || u.Scheme == "" || u.Host == "" { + r.renderCallbackError(w, "MCP client registration is no longer valid and the redirect_uri is not a valid URL — please reconnect your MCP client.") + return + } + r.logger.WithField("redirect_uri", redirectURI).Info("stale client_id — transparently re-registering MCP client for this authorize request") + reg = ClientRegistrationData{ + ClientIDIssuedAt: time.Now().UTC().Unix(), + RedirectURIs: []string{redirectURI}, + TokenEndpointAuthMethod: "none", + } } if !contains(reg.RedirectURIs, redirectURI) { http.Error(w, "redirect_uri not registered", http.StatusBadRequest) diff --git a/pkg/oauth/login.go b/pkg/oauth/login.go index d9c5108..dc9ccba 100644 --- a/pkg/oauth/login.go +++ b/pkg/oauth/login.go @@ -94,7 +94,7 @@ type loginPageData struct { func (r *Router) vaultLogin(w http.ResponseWriter, req *http.Request) { switch req.Method { case http.MethodGet: - r.renderLogin(w, req, req.URL.Query().Get("auth_state"), "") + r.renderLogin(w, req, req.URL.Query().Get("auth_state"), req.URL.Query().Get("error")) case http.MethodPost: r.handleLoginSubmit(w, req) default: @@ -202,6 +202,41 @@ func (r *Router) issueAuthCodeAndRedirect(w http.ResponseWriter, req *http.Reque _ = loginTemplate.Execute(w, loginPageData{RedirectURL: cb.String()}) } +// renderCallbackError renders a standalone error card using the same styles as +// the login page. It is used by the OIDC callback handler when it cannot +// redirect back to the login page (e.g., unknown or expired pending state). +func (r *Router) renderCallbackError(w http.ResponseWriter, msg string) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + _ = callbackErrorTemplate.Execute(w, msg) +} + +var callbackErrorTemplate = template.Must(template.New("callbackError").Parse(` + + + + + Vault MCP — Sign In Error + + + +
+

Vault MCP Server

+
{{.}}
+
Please close this tab and restart the sign-in flow from your MCP client.
+
+ +`)) + func truncateErr(err error) string { s := strings.ReplaceAll(err.Error(), "\n", " ") if len(s) > 240 { diff --git a/pkg/oauth/oidc.go b/pkg/oauth/oidc.go index 32ac678..af6fef7 100644 --- a/pkg/oauth/oidc.go +++ b/pkg/oauth/oidc.go @@ -106,8 +106,42 @@ func (r *Router) oidcCallback(w http.ResponseWriter, req *http.Request) { q := req.URL.Query() vaultState := q.Get("state") code := q.Get("code") + oidcErr := q.Get("error") + + // IdP returned an error (e.g. access_denied from bad credentials). + // Recover the pending state so we can redirect back to the login page with + // a human-readable error message rendered in the same styled UI. + if oidcErr != "" { + errDesc := q.Get("error_description") + if errDesc == "" { + errDesc = oidcErr + } + friendlyMsg := "OIDC sign-in failed: " + errDesc + + if vaultState != "" { + if v, ok := oidcStateMap.LoadAndDelete(vaultState); ok { + pending := v.(oidcPending) + if !time.Now().After(pending.ExpiresAt) { + serverURL := r.cfg.ServerURL + if serverURL == "" { + serverURL = r.cfg.BaseURL(req) + } + loginURL := serverURL + "/vault/login?auth_state=" + + url.QueryEscape(pending.SealedAuthState) + "&error=" + + url.QueryEscape(friendlyMsg) + http.Redirect(w, req, loginURL, http.StatusFound) + return + } + } + } + + // Pending state not found or expired — render a standalone error card. + r.renderCallbackError(w, friendlyMsg) + return + } + if vaultState == "" || code == "" { - http.Error(w, "missing state or code", http.StatusBadRequest) + r.renderCallbackError(w, "missing state or code in callback — please restart the login") return } From 490ffd265af3542db1b2cab8755911da18ce65df Mon Sep 17 00:00:00 2001 From: Marcello DeSales Date: Tue, 7 Jul 2026 10:56:13 -0700 Subject: [PATCH 16/18] :hamster: :bug: fix secrets read from the service --- pkg/tools/kv/read_folder.go | 290 ++++++++++++++++++++++++++++++++++++ pkg/tools/kv/read_secret.go | 70 ++------- pkg/tools/tools.go | 3 + 3 files changed, 304 insertions(+), 59 deletions(-) create mode 100644 pkg/tools/kv/read_folder.go diff --git a/pkg/tools/kv/read_folder.go b/pkg/tools/kv/read_folder.go new file mode 100644 index 0000000..d9ea1b2 --- /dev/null +++ b/pkg/tools/kv/read_folder.go @@ -0,0 +1,290 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package kv + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strings" + "sync" + + "github.com/hashicorp/vault-mcp-server/pkg/client" + "github.com/hashicorp/vault-mcp-server/pkg/utils" + vaultapi "github.com/hashicorp/vault/api" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + log "github.com/sirupsen/logrus" +) + +type folderEntry struct { + Path string `json:"path"` + Keys []string `json:"keys,omitempty"` + SHA256 map[string]string `json:"sha256,omitempty"` + Data map[string]interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +type folderResult struct { + Mount string `json:"mount"` + Path string `json:"path"` + Count int `json:"count"` + Secrets []folderEntry `json:"secrets"` +} + +// ReadFolder creates a bulk-read tool that collapses an entire KV folder into one MCP response. +func ReadFolder(logger *log.Logger) server.ServerTool { + return server.ServerTool{ + Tool: mcp.NewTool("read_folder", + mcp.WithDescription("Read ALL secrets under a KV mount path in one call. "+ + "Defaults to key names + SHA-256 of each value (safe, no plaintext exposure). "+ + "Set include_values=true to return actual secret values."), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ReadOnlyHint: utils.ToBoolPtr(true)}), + mcp.WithString("mount", + mcp.Required(), + mcp.Description("The mount path of the secret engine (e.g. 'secrets')."), + ), + mcp.WithString("path", + mcp.Required(), + mcp.Description("Folder path within the mount (e.g. 'application/prod'). Use '' for the root of the mount."), + ), + mcp.WithBoolean("include_values", + mcp.DefaultBool(false), + mcp.Description("Return actual secret values; default false returns key names + SHA-256 fingerprints only."), + ), + mcp.WithString("filter_keys", + mcp.DefaultString(""), + mcp.Description("Comma-separated list of key names to include from each secret; empty means all keys."), + ), + mcp.WithBoolean("recursive", + mcp.DefaultBool(false), + mcp.Description("Recursively read sub-folders; default false reads only the immediate folder."), + ), + mcp.WithNumber("max_concurrency", + mcp.DefaultNumber(10), + mcp.Description("Maximum number of concurrent secret reads (1–50); default 10."), + ), + ), + Handler: func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return readFolderHandler(ctx, req, logger) + }, + } +} + +func readFolderHandler(ctx context.Context, req mcp.CallToolRequest, logger *log.Logger) (*mcp.CallToolResult, error) { + logger.Debug("Handling read_folder request") + + args, ok := req.Params.Arguments.(map[string]interface{}) + if !ok { + return mcp.NewToolResultError("Missing or invalid arguments format"), nil + } + + mount, err := utils.ExtractMountPath(args) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + folderPath, _ := args["path"].(string) + folderPath = strings.Trim(folderPath, "/") + + includeValues, _ := args["include_values"].(bool) + recursive, _ := args["recursive"].(bool) + + var filterKeys []string + if fk, _ := args["filter_keys"].(string); fk != "" { + for _, k := range strings.Split(fk, ",") { + if k = strings.TrimSpace(k); k != "" { + filterKeys = append(filterKeys, k) + } + } + } + + maxConcurrency := 10 + if mc, ok := args["max_concurrency"].(float64); ok && mc >= 1 { + maxConcurrency = int(mc) + if maxConcurrency > 50 { + maxConcurrency = 50 + } + } + + vault, err := client.GetVaultClientFromContext(ctx, logger) + if err != nil { + logger.WithError(err).Error("Failed to get Vault client") + return mcp.NewToolResultError(fmt.Sprintf("Failed to get Vault client: %v", err)), nil + } + + leafPaths, err := collectLeafPaths(ctx, vault, mount, folderPath, recursive) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to list folder: %v", err)), nil + } + + if len(leafPaths) == 0 { + out, _ := json.Marshal(folderResult{Mount: mount, Path: folderPath, Count: 0, Secrets: []folderEntry{}}) + return mcp.NewToolResultText(string(out)), nil + } + + logger.WithFields(log.Fields{ + "mount": mount, + "path": folderPath, + "count": len(leafPaths), + }).Debug("Reading secrets from folder") + + entries := make([]folderEntry, len(leafPaths)) + sem := make(chan struct{}, maxConcurrency) + var wg sync.WaitGroup + var mu sync.Mutex + + for i, lp := range leafPaths { + wg.Add(1) + sem <- struct{}{} + go func(idx int, secretPath string) { + defer wg.Done() + defer func() { <-sem }() + + entry := folderEntry{Path: secretPath} + kvSecret, readErr := vault.KVv2(mount).Get(ctx, secretPath) + if readErr != nil { + entry.Error = readErr.Error() + } else if kvSecret == nil || kvSecret.Data == nil { + entry.Error = "secret not found" + } else { + data := kvSecret.Data + if len(filterKeys) > 0 { + data = applyKeyFilter(data, filterKeys) + } + if includeValues { + entry.Data = data + } else { + entry.Keys = sortedMapKeys(data) + entry.SHA256 = computeValueSHA256(data) + } + } + + mu.Lock() + entries[idx] = entry + mu.Unlock() + }(i, lp) + } + wg.Wait() + + result := folderResult{ + Mount: mount, + Path: folderPath, + Count: len(entries), + Secrets: entries, + } + out, err := json.Marshal(result) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Error marshaling result: %v", err)), nil + } + + logger.WithFields(log.Fields{ + "mount": mount, + "path": folderPath, + "count": len(entries), + }).Debug("Successfully read folder") + + return mcp.NewToolResultText(string(out)), nil +} + +// collectLeafPaths lists all non-directory secret paths under basePath in the KV v2 mount. +// Directories (keys ending in "/") are recursed when recursive=true. +func collectLeafPaths(ctx context.Context, vault *vaultapi.Client, mount, basePath string, recursive bool) ([]string, error) { + listPath := mount + "/metadata" + if basePath != "" { + listPath += "/" + basePath + } else { + listPath += "/" + } + + secret, err := vault.Logical().ListWithContext(ctx, listPath) + if err != nil { + return nil, err + } + if secret == nil || secret.Data == nil { + return nil, nil + } + + rawKeys, _ := secret.Data["keys"] + var keys []string + switch v := rawKeys.(type) { + case []interface{}: + for _, k := range v { + if s, ok := k.(string); ok { + keys = append(keys, s) + } + } + case []string: + keys = v + } + + var leaves []string + for _, key := range keys { + if strings.HasSuffix(key, "/") { + if !recursive { + continue + } + sub := strings.TrimSuffix(key, "/") + subPath := sub + if basePath != "" { + subPath = basePath + "/" + sub + } + subs, err := collectLeafPaths(ctx, vault, mount, subPath, recursive) + if err != nil { + return nil, err + } + leaves = append(leaves, subs...) + } else { + leafPath := key + if basePath != "" { + leafPath = basePath + "/" + key + } + leaves = append(leaves, leafPath) + } + } + return leaves, nil +} + +func applyKeyFilter(data map[string]interface{}, filterKeys []string) map[string]interface{} { + keep := make(map[string]bool, len(filterKeys)) + for _, k := range filterKeys { + keep[k] = true + } + out := make(map[string]interface{}, len(filterKeys)) + for k, v := range data { + if keep[k] { + out[k] = v + } + } + return out +} + +func sortedMapKeys(data map[string]interface{}) []string { + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// computeValueSHA256 returns a map of key → hex(sha256(value)) for each entry. +// String values are hashed as raw bytes; other types are JSON-serialized first. +func computeValueSHA256(data map[string]interface{}) map[string]string { + out := make(map[string]string, len(data)) + for k, v := range data { + var b []byte + if s, ok := v.(string); ok { + b = []byte(s) + } else { + b, _ = json.Marshal(v) + } + sum := sha256.Sum256(b) + out[k] = hex.EncodeToString(sum[:]) + } + return out +} diff --git a/pkg/tools/kv/read_secret.go b/pkg/tools/kv/read_secret.go index 160acaf..de8b103 100644 --- a/pkg/tools/kv/read_secret.go +++ b/pkg/tools/kv/read_secret.go @@ -7,11 +7,10 @@ import ( "context" "encoding/json" "fmt" - "github.com/hashicorp/vault-mcp-server/pkg/client" - "github.com/hashicorp/vault-mcp-server/pkg/utils" - "strings" + "github.com/hashicorp/vault-mcp-server/pkg/client" + "github.com/hashicorp/vault-mcp-server/pkg/utils" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" log "github.com/sirupsen/logrus" @@ -30,6 +29,10 @@ func ReadSecret(logger *log.Logger) server.ServerTool { mcp.Required(), mcp.Description("The full path to read the secret to without the mount prefix. For example, if you want to read from 'secrets/application/credentials', this should be 'application/credentials'."), ), + mcp.WithString("key", + mcp.DefaultString(""), + mcp.Description("A optional key in the secret to delete. If not specified, all keys in the the secret will be deleted."), + ), ), Handler: func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { return readSecretHandler(ctx, req, logger) @@ -40,7 +43,6 @@ func ReadSecret(logger *log.Logger) server.ServerTool { func readSecretHandler(ctx context.Context, req mcp.CallToolRequest, logger *log.Logger) (*mcp.CallToolResult, error) { logger.Debug("Handling read_secret request") - // Extract parameters args, ok := req.Params.Arguments.(map[string]interface{}) if !ok { return mcp.NewToolResultError("Missing or invalid arguments format"), nil @@ -61,47 +63,22 @@ func readSecretHandler(ctx context.Context, req mcp.CallToolRequest, logger *log "path": path, }).Debug("Reading secret") - // Get Vault client from context vault, err := client.GetVaultClientFromContext(ctx, logger) if err != nil { logger.WithError(err).Error("Failed to get Vault client") return mcp.NewToolResultError(fmt.Sprintf("Failed to get Vault client: %v", err)), nil } - mounts, err := vault.Sys().ListMounts() - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to list mounts: %v", err)), nil - } - - // Default to a v1 KV path - fullPath := fmt.Sprintf("%s/%s", mount, strings.TrimPrefix(path, "/")) - - isV2 := false - - // Check if the mount exists - if m, ok := mounts[mount+"/"]; ok { - // is it a KV v2 mount? - if m.Options["version"] == "2" { - isV2 = true - // Construct the full path for reading (KV v2 format) - fullPath = fmt.Sprintf("%s/data/%s", mount, strings.TrimPrefix(path, "/")) - } - } else { - return mcp.NewToolResultError(fmt.Sprintf("mount path '%s' does not exist. Use 'create_mount' with the type kv2 to create the mount.", mount)), nil - } - - // Read the secret - secret, err := vault.Logical().Read(fullPath) + kvSecret, err := vault.KVv2(mount).Get(ctx, strings.TrimPrefix(path, "/")) if err != nil { logger.WithError(err).WithFields(log.Fields{ - "mount": mount, - "path": path, - "full_path": fullPath, + "mount": mount, + "path": path, }).Error("Failed to read secret") return mcp.NewToolResultError(fmt.Sprintf("Failed to read secret: %v", err)), nil } - if secret == nil { + if kvSecret == nil || kvSecret.Data == nil { logger.WithFields(log.Fields{ "mount": mount, "path": path, @@ -109,32 +86,7 @@ func readSecretHandler(ctx context.Context, req mcp.CallToolRequest, logger *log return mcp.NewToolResultError(fmt.Sprintf("Secret not found at path '%s' in mount '%s'. Use 'write_secret' to write a new secret at that path.", path, mount)), nil } - // Handle the data structure differently for v1 and v2 - var secretData interface{} - - if isV2 { - if secret.Data["data"] == nil { - metaData, ok := secret.Data["metadata"].(map[string]interface{}) - if !ok { - return mcp.NewToolResultError("unexpected secret metadata format for v2 API"), nil - } - if metaData["deletion_time"] != nil { - return mcp.NewToolResultError(fmt.Sprintf("Secret at path '%s' in mount '%s' is deleted and cannot be read.", path, mount)), nil - } - } - // V2 API structure: secret.Data["data"] contains the actual key-value pairs - data, ok := secret.Data["data"].(map[string]interface{}) - if !ok { - return mcp.NewToolResultError("unexpected secret data format for v2 API"), nil - } - secretData = data - } else { - // V1 API structure: secret.Data directly contains the key-value pairs - secretData = secret.Data - } - - // Marshal to JSON - jsonData, err := json.Marshal(secretData) + jsonData, err := json.Marshal(kvSecret.Data) if err != nil { logger.WithError(err).Error("Failed to marshal secret to JSON") return mcp.NewToolResultError(fmt.Sprintf("Error marshaling JSON: %v", err)), nil diff --git a/pkg/tools/tools.go b/pkg/tools/tools.go index 0a92838..b4a2485 100644 --- a/pkg/tools/tools.go +++ b/pkg/tools/tools.go @@ -30,6 +30,9 @@ func InitTools(hcServer *server.MCPServer, logger *log.Logger) { readSecretTool := kv.ReadSecret(logger) hcServer.AddTool(readSecretTool.Tool, readSecretTool.Handler) + readFolderTool := kv.ReadFolder(logger) + hcServer.AddTool(readFolderTool.Tool, readFolderTool.Handler) + writeSecretTool := kv.WriteSecret(logger) hcServer.AddTool(writeSecretTool.Tool, writeSecretTool.Handler) From 3677000ccd771357defdbb830d0b94b7ad6041c4 Mon Sep 17 00:00:00 2001 From: Marcello DeSales Date: Fri, 10 Jul 2026 14:27:36 -0700 Subject: [PATCH 17/18] fix(oauth): make login page form actions base-path aware The interactive login page hard-coded root-relative form actions (action="/vault/login", action="/vault/oidc/start"). Behind a reverse-proxy path prefix (e.g. https://host/mcps/vault-mcp), the browser resolves those against the HOST ROOT and POSTs to /vault/oidc/start -> 404, so OIDC/LDAP sign-in never starts. Terminal MCP clients (Claude Code, WARP) surfaced this after OAuth discovery succeeded. It only worked on localhost because the server is at the root there. Fix: derive Config.BasePath() from MCP_SERVER_URL (the public mount point; a proxy strips the prefix before it reaches us, so the request can't reveal it) and prefix the login form actions with it. Empty at the host root, so localhost is unchanged. All server-issued redirects already build from ServerURL and were unaffected. - pkg/oauth/config.go: add Config.BasePath() - pkg/oauth/login.go: loginPageData.BasePath; forms use {{.BasePath}}/vault/... - tests: BasePath cases + login template renders prefixed vs root actions Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/oauth/config.go | 22 ++++++++++++++++ pkg/oauth/config_test.go | 29 +++++++++++++++++++++ pkg/oauth/login.go | 9 +++++-- pkg/oauth/login_test.go | 56 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 pkg/oauth/config_test.go create mode 100644 pkg/oauth/login_test.go diff --git a/pkg/oauth/config.go b/pkg/oauth/config.go index 629df37..2c5cb6e 100644 --- a/pkg/oauth/config.go +++ b/pkg/oauth/config.go @@ -6,6 +6,7 @@ package oauth import ( "fmt" "net/http" + "net/url" "os" "strconv" "strings" @@ -101,6 +102,27 @@ func (c Config) BaseURL(r *http.Request) string { return scheme + "://" + strings.TrimSpace(host) } +// BasePath returns the URL path prefix under which this server is publicly +// mounted (e.g. "/mcps/vault-mcp"), derived from ServerURL. It is empty when the +// server is served at the host root or ServerURL is unset. +// +// Interactive HTML served by this server (the login page's form actions) MUST +// prefix its links with this value. A reverse proxy strips the prefix before the +// request reaches us, so we never see it on the wire and cannot infer it from the +// request; only ServerURL carries the public mount point. Root-relative links +// (e.g. "/vault/oidc/start") resolve against the host root in the browser and 404 +// behind a path prefix. +func (c Config) BasePath() string { + if c.ServerURL == "" { + return "" + } + u, err := url.Parse(c.ServerURL) + if err != nil { + return "" + } + return strings.TrimRight(u.Path, "/") +} + func getenv(key, fallback string) string { if v, ok := os.LookupEnv(key); ok && strings.TrimSpace(v) != "" { return strings.TrimSpace(v) diff --git a/pkg/oauth/config_test.go b/pkg/oauth/config_test.go new file mode 100644 index 0000000..3e99fab --- /dev/null +++ b/pkg/oauth/config_test.go @@ -0,0 +1,29 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package oauth + +import "testing" + +func TestConfigBasePath(t *testing.T) { + cases := []struct { + name string + serverURL string + want string + }{ + {"prefixed", "https://dev.vionix.kortex.vionix.viasat.io/mcps/vault-mcp", "/mcps/vault-mcp"}, + {"prefixed trailing slash", "https://host/mcps/vault-mcp/", "/mcps/vault-mcp"}, + {"deep path", "https://host/a/b/c", "/a/b/c"}, + {"root host only", "https://host", ""}, + {"root slash", "https://host/", ""}, + {"empty (localhost/no prefix)", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := Config{ServerURL: tc.serverURL}.BasePath() + if got != tc.want { + t.Errorf("BasePath(%q) = %q, want %q", tc.serverURL, got, tc.want) + } + }) + } +} diff --git a/pkg/oauth/login.go b/pkg/oauth/login.go index dc9ccba..9aca511 100644 --- a/pkg/oauth/login.go +++ b/pkg/oauth/login.go @@ -50,7 +50,7 @@ var loginTemplate = template.Must(template.New("login").Parse(`

Sign in to {{.VaultAddr}}. Your Vault token is encrypted into your MCP bearer token.

{{if .Error}}
{{.Error}}
{{end}} -
+ @@ -88,6 +88,10 @@ type loginPageData struct { Error string RedirectURL string OIDCEnabled bool + // BasePath is the public path prefix (e.g. "/mcps/vault-mcp") the server is + // mounted under, prepended to the login/OIDC form actions so they resolve + // behind a reverse-proxy prefix instead of the host root. Empty at the root. + BasePath string } // vaultLogin renders the login page (GET) and handles credential submission (POST). @@ -114,6 +118,7 @@ func (r *Router) renderLogin(w http.ResponseWriter, req *http.Request, authState VaultAddr: r.vaultAuthParams().Address, Error: errMsg, OIDCEnabled: true, + BasePath: r.cfg.BasePath(), }) } diff --git a/pkg/oauth/login_test.go b/pkg/oauth/login_test.go new file mode 100644 index 0000000..d468213 --- /dev/null +++ b/pkg/oauth/login_test.go @@ -0,0 +1,56 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package oauth + +import ( + "bytes" + "strings" + "testing" +) + +// TestLoginTemplateBasePath ensures the login page's form actions are prefixed +// with the public BasePath so they resolve behind a reverse-proxy path prefix +// (e.g. /mcps/vault-mcp) instead of the host root — the bug that broke terminal +// MCP clients ("No authorization support detected" / 404 on /vault/oidc/start). +func TestLoginTemplateBasePath(t *testing.T) { + render := func(basePath string) string { + var buf bytes.Buffer + if err := loginTemplate.Execute(&buf, loginPageData{ + AuthState: "state", + VaultAddr: "https://vault.example.com", + OIDCEnabled: true, + BasePath: basePath, + }); err != nil { + t.Fatalf("execute template: %v", err) + } + return buf.String() + } + + t.Run("behind path prefix", func(t *testing.T) { + out := render("/mcps/vault-mcp") + for _, want := range []string{ + `action="/mcps/vault-mcp/vault/login"`, + `action="/mcps/vault-mcp/vault/oidc/start"`, + } { + if !strings.Contains(out, want) { + t.Errorf("login page missing prefixed form action %q", want) + } + } + if strings.Contains(out, `action="/vault/login"`) { + t.Error("login page still emits a root-relative action (would 404 behind a prefix)") + } + }) + + t.Run("at host root", func(t *testing.T) { + out := render("") + for _, want := range []string{ + `action="/vault/login"`, + `action="/vault/oidc/start"`, + } { + if !strings.Contains(out, want) { + t.Errorf("root login page missing form action %q", want) + } + } + }) +} From 7ce384f279c6cc21fccaa113897ff2f1993d4d8f Mon Sep 17 00:00:00 2001 From: Marcello DeSales Date: Fri, 10 Jul 2026 15:47:27 -0700 Subject: [PATCH 18/18] ci: retrigger docker-multiarch after digest-step fix in github-platform@main Reusable workflow re-runs pin the original resolved SHA; an empty commit forces a fresh pull_request run that resolves docker-multiarch-cicd.yaml@main (now containing the env-based bake-metadata digest fix).
Streamable HTTP modeStdio mode