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 diff --git a/README.md b/README.md index 859009b..3fab896 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`) @@ -80,26 +80,99 @@ 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. 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`) +- `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; 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 -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: + +- **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. -- **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 +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**: 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 +## 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). + +`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). + +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 +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 +``` + +### 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 three ways to authenticate: + - **LDAP** — `auth//login/` + - **Userpass** — `auth//login/` + - **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. 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. + + ## 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. + @@ -107,27 +180,9 @@ The HTTP server includes a comprehensive middleware stack: ```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" } } } @@ -234,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 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..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" @@ -17,6 +19,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,35 +142,78 @@ 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.") + var streamableServer http.Handler = baseStreamableServer + + mux := http.NewServeMux() + + // 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") } - // Create a security wrapper around the streamable server - streamableServer := client.NewSecurityHandler(baseStreamableServer, corsConfig.AllowedOrigins, corsConfig.Mode, logger) + // 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). + if err := requireConfiguredCACert(logger); err != nil { + return err + } - mux := http.NewServeMux() + 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, + } + 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 + // 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) @@ -196,18 +242,59 @@ 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 } - logger.Warnf("TLS is disabled on StreamableHTTP server; this is not recommended for production") + + 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") + + 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 ───────────────────────────────────────────────── // Start server in goroutine errC := make(chan error, 1) go func() { - logger.Infof("Starting StreamableHTTP server on %s%s", addr, endpointPath) errC <- httpServer.ListenAndServe() }() @@ -355,3 +442,40 @@ 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} { + 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-ENTERPRISE_EXAMPLE.yaml b/docker-compose-ENTERPRISE_EXAMPLE.yaml new file mode 100644 index 0000000..43845cd --- /dev/null +++ b/docker-compose-ENTERPRISE_EXAMPLE.yaml @@ -0,0 +1,104 @@ +#### +#### Vault MCP Server — StreamableHTTP transport with optional 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): +#### # 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 / 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-certs/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). 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:-} + + # --- Upstream Vault --- + VAULT_ADDR: ${VAULT_ADDR:-https://vault.seceng-iam.enterprise.io} + VAULT_NAMESPACE: ${VAULT_NAMESPACE:-} + + # Token bypass: set VAULT_TOKEN (e.g. via .env) to skip browser OAuth. + 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/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..76435e0 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,77 @@ +#### +#### Vault MCP Server — StreamableHTTP transport with optional browser OAuth. +#### +#### 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): +#### # 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 / 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: 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" + + restart: unless-stopped + + volumes: + # Optional: mount a private CA bundle into the container (and set VAULT_CACERT to trust it). + - ./data/certs:/company/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). 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:-} + + # --- Upstream Vault --- + VAULT_ADDR: ${VAULT_ADDR:-https://vault.company.com} + VAULT_NAMESPACE: ${VAULT_NAMESPACE:-} + + # Token bypass: set VAULT_TOKEN (e.g. via .env) to skip browser OAuth. + VAULT_TOKEN: ${VAULT_TOKEN:-} + + # --- 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} + 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} 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/client/client.go b/pkg/client/client.go index e44f9e0..b3e32dc 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 (typically provisioned via an out-of-process bootstrap step). + 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 @@ -78,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) } @@ -167,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/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..2c5cb6e --- /dev/null +++ b/pkg/oauth/config.go @@ -0,0 +1,164 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package oauth + +import ( + "fmt" + "net/http" + "net/url" + "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) +} + +// 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) + } + 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/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/handlers.go b/pkg/oauth/handlers.go new file mode 100644 index 0000000..bedaf82 --- /dev/null +++ b/pkg/oauth/handlers.go @@ -0,0 +1,245 @@ +// 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 { + // 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) + 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..9aca511 --- /dev/null +++ b/pkg/oauth/login.go @@ -0,0 +1,251 @@ +// 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 + // 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). +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"), req.URL.Query().Get("error")) + 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, + BasePath: r.cfg.BasePath(), + }) +} + +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() + + 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 "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 + } + + 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()}) +} + +// 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 { + return s[:240] + "…" + } + return s +} 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) + } + } + }) +} 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..af6fef7 --- /dev/null +++ b/pkg/oauth/oidc.go @@ -0,0 +1,209 @@ +// 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") + 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 == "" { + r.renderCallbackError(w, "missing state or code in callback — please restart the login") + 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..5d96bb5 --- /dev/null +++ b/pkg/oauth/router.go @@ -0,0 +1,154 @@ +// 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. +// +// 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 { + ctx := client.ContextWithVaultAuth(req.Context(), at.VaultAddr, at.VaultToken, at.VaultNamespace) + next.ServeHTTP(w, req.WithContext(ctx)) + return + } + // Fall through to external token bypass. + } + + // 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 + } + + 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"` +} 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/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/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 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) diff --git a/scripts/fetch-certs/Dockerfile b/scripts/fetch-certs/Dockerfile new file mode 100644 index 0000000..ad62758 --- /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-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 +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..4748e95 --- /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, 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) +}
Streamable HTTP modeStdio mode