Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ The server can be configured using environment variables:
- `MCP_TLS_KEY_FILE`: Location of the TLS key file (e.g. `/path/to/key.pem`)(default: `""`)
- `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`)
- `VAULT_AUTH_METHOD`: `token` (default) or `jwt`. See [JWT Auth Mode](#jwt-auth-mode-gateway-deployments) below.
- `VAULT_AUTH_JWT_PATH`: Mount path of Vault's JWT auth method (default: `jwt`)
- `VAULT_AUTH_JWT_ROLE`: Vault role to authenticate against (required when `VAULT_AUTH_METHOD=jwt`)
- `VAULT_AUTH_JWT_HEADER`: Header the incoming JWT is read from (default: `Authorization`, expects a `Bearer <jwt>` value)
- `VAULT_AUTH_JWT_CACHE_TTL`: Optional cap, in seconds, on how long an exchanged Vault token is cached (default: the token's own lease duration)

## HTTP Mode Configuration

Expand All @@ -96,6 +101,27 @@ The HTTP server includes a comprehensive middleware stack:
- **Vault Context Middleware**: Extracts Vault configuration and adds to request context
- **Logging Middleware**: Structured HTTP request logging

### JWT auth mode (gateway deployments)

By default the server authenticates to Vault with a static token (`VAULT_TOKEN` or `X-Vault-Token`). Every request then reaches Vault as the same identity, which doesn't work well behind a gateway that already knows who the user is.

Set `VAULT_AUTH_METHOD=jwt` to have the server exchange the caller's JWT for a short-lived, user-scoped Vault token on each request instead:

1. A reverse proxy or gateway (e.g. [Toolhive](https://docs.stacklok.com/toolhive)) authenticates the user via OIDC and forwards their token as `Authorization: Bearer <jwt>`.
2. The server reads that header and calls Vault's `POST /v1/auth/<VAULT_AUTH_JWT_PATH>/login` with the configured `VAULT_AUTH_JWT_ROLE` and the JWT.
3. Vault verifies the JWT itself (signature, issuer, audience, per the JWT auth method's configuration) and returns a token scoped to whatever policies that role maps to.
4. The resulting token is used for the rest of the request and cached in memory, keyed by the JWT, until it's close to expiry (or until `VAULT_AUTH_JWT_CACHE_TTL` elapses, if set).

`VAULT_TOKEN` and `X-Vault-Token` are ignored while JWT mode is active. If the JWT is missing or Vault rejects it, the request fails with 401 or 403 rather than falling back to a static token.

```bash
export VAULT_AUTH_METHOD=jwt
export VAULT_AUTH_JWT_ROLE=mcp-gateway
export VAULT_ADDR=https://vault.internal:8200
```

This assumes Vault's JWT auth method is already enabled and mapped to your identity provider; see [Vault's JWT auth docs](https://developer.hashicorp.com/vault/docs/auth/jwt) for that side of the setup.

## 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.
Expand Down
20 changes: 15 additions & 5 deletions pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ 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
// buildVaultClient creates an *api.Client for the given address and TLS
// setting, without touching the session cache or setting a token.
func buildVaultClient(vaultAddress string, vaultSkipTLSVerify bool, vaultNamespace string) (*api.Client, error) {
config := api.DefaultConfig()
config.Address = vaultAddress

Expand All @@ -72,12 +72,22 @@ func NewVaultClient(sessionId string, vaultAddress string, vaultSkipTLSVerify bo
return nil, fmt.Errorf("api.NewClient failed to create Vault client: %v", err)
}

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 := buildVaultClient(vaultAddress, vaultSkipTLSVerify, vaultNamespace)
if err != nil {
return nil, err
}

client.SetToken(vaultToken)

activeClients.Store(sessionId, &sessionEntry{client: client, tokenHash: hashToken(vaultToken)})

return client, nil
Expand Down
188 changes: 188 additions & 0 deletions pkg/client/jwt_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// Copyright IBM Corp. 2025, 2026
// SPDX-License-Identifier: MPL-2.0

package client

import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"

"github.com/hashicorp/vault/api"
log "github.com/sirupsen/logrus"
)

const (
VaultAuthMethod = "VAULT_AUTH_METHOD"
VaultAuthJWTPath = "VAULT_AUTH_JWT_PATH"
VaultAuthJWTRole = "VAULT_AUTH_JWT_ROLE"
VaultAuthJWTHeader = "VAULT_AUTH_JWT_HEADER"
VaultAuthJWTCacheTTL = "VAULT_AUTH_JWT_CACHE_TTL"
)

const (
defaultJWTAuthPath = "jwt"
defaultJWTAuthHeader = "Authorization"

// cacheSafetyMargin keeps a cached token from being handed out right at
// the edge of its lease expiry.
cacheSafetyMargin = 10 * time.Second
)

// jwtAuthEnabled reports whether VAULT_AUTH_METHOD=jwt is configured.
func jwtAuthEnabled() bool {
return strings.EqualFold(getEnv(VaultAuthMethod, "token"), "jwt")
}

// jwtAuthHeaderName returns the header the incoming JWT is read from.
func jwtAuthHeaderName() string {
return getEnv(VaultAuthJWTHeader, defaultJWTAuthHeader)
}

// jwtLoginError is a JWT exchange failure carrying the HTTP status the
// caller should see. Acceptance criteria for this feature require that
// exchange failures are surfaced clearly rather than silently falling back
// to a static token, so this type is what the middleware inspects to pick
// the response status.
type jwtLoginError struct {
status int
message string
}

func (e *jwtLoginError) Error() string { return e.message }
func (e *jwtLoginError) StatusCode() int { return e.status }

// extractBearerToken pulls the JWT out of a header value such as
// "Bearer <token>". A bare token, with no "Bearer " prefix, is also
// accepted since some gateways forward the JWT without it.
func extractBearerToken(headerValue string) (string, error) {
headerValue = strings.TrimSpace(headerValue)
if headerValue == "" {
return "", &jwtLoginError{http.StatusUnauthorized, fmt.Sprintf("missing JWT: %s header not provided", jwtAuthHeaderName())}
}
if rest, ok := strings.CutPrefix(headerValue, "Bearer "); ok {
headerValue = strings.TrimSpace(rest)
} else if strings.EqualFold(headerValue, "Bearer") {
headerValue = ""
}
if headerValue == "" {
return "", &jwtLoginError{http.StatusUnauthorized, "missing JWT: empty bearer token"}
}
return headerValue, nil
}

type cachedToken struct {
token string
expiresAt time.Time
}

// jwtTokenCache maps sha256(jwt) to the Vault token obtained for it, so
// repeat requests within the token's lease don't re-hit /login.
var jwtTokenCache sync.Map

func cacheKey(jwt string) string {
sum := sha256.Sum256([]byte(jwt))
return hex.EncodeToString(sum[:])
}

func lookupCachedToken(jwt string) (string, bool) {
key := cacheKey(jwt)
value, ok := jwtTokenCache.Load(key)
if !ok {
return "", false
}
entry := value.(cachedToken)
if time.Now().After(entry.expiresAt) {
jwtTokenCache.Delete(key)
return "", false
}
return entry.token, true
}

func storeCachedToken(jwt, vaultToken string, leaseDuration time.Duration) {
if leaseDuration <= cacheSafetyMargin {
// Lease too short to be worth caching; every request will just
// exchange again.
return
}

if cap := getEnv(VaultAuthJWTCacheTTL, ""); cap != "" {
if capSeconds, err := strconv.Atoi(cap); err == nil {
if capDuration := time.Duration(capSeconds) * time.Second; capDuration < leaseDuration {
leaseDuration = capDuration
}
}
}

jwtTokenCache.Store(cacheKey(jwt), cachedToken{
token: vaultToken,
expiresAt: time.Now().Add(leaseDuration - cacheSafetyMargin),
})
}

// exchangeJWTForVaultToken calls Vault's JWT auth login endpoint and returns
// a short-lived, user-scoped Vault token plus its lease duration.
func exchangeJWTForVaultToken(vaultAddress, vaultNamespace string, vaultSkipTLSVerify bool, jwt string) (string, time.Duration, error) {
role := getEnv(VaultAuthJWTRole, "")
if role == "" {
return "", 0, &jwtLoginError{http.StatusUnauthorized, "VAULT_AUTH_JWT_ROLE is not configured"}
}
mount := getEnv(VaultAuthJWTPath, defaultJWTAuthPath)

loginClient, err := buildVaultClient(vaultAddress, vaultSkipTLSVerify, vaultNamespace)
if err != nil {
return "", 0, fmt.Errorf("failed to build Vault client for JWT login: %w", err)
}

secret, err := loginClient.Logical().Write(fmt.Sprintf("auth/%s/login", mount), map[string]interface{}{
"role": role,
"jwt": jwt,
})
if err != nil {
var respErr *api.ResponseError
if errors.As(err, &respErr) {
status := http.StatusUnauthorized
if respErr.StatusCode == http.StatusForbidden {
status = http.StatusForbidden
}
msg := "vault rejected the JWT"
if len(respErr.Errors) > 0 {
msg = strings.Join(respErr.Errors, "; ")
}
return "", 0, &jwtLoginError{status, msg}
}
// Not a Vault API error, e.g. the login request never reached
// Vault. That's a connectivity problem, not an auth rejection.
return "", 0, &jwtLoginError{http.StatusServiceUnavailable, fmt.Sprintf("could not reach Vault to exchange JWT: %v", err)}
}
if secret == nil || secret.Auth == nil || secret.Auth.ClientToken == "" {
return "", 0, &jwtLoginError{http.StatusUnauthorized, "vault JWT login returned no token"}
}

return secret.Auth.ClientToken, time.Duration(secret.Auth.LeaseDuration) * time.Second, nil
}

// resolveJWTVaultToken returns a Vault token for the given JWT, using the
// in-memory cache when possible and exchanging with Vault otherwise.
func resolveJWTVaultToken(vaultAddress, vaultNamespace string, vaultSkipTLSVerify bool, jwt string, logger *log.Logger) (string, error) {
if cached, ok := lookupCachedToken(jwt); ok {
return cached, nil
}

vaultToken, leaseDuration, err := exchangeJWTForVaultToken(vaultAddress, vaultNamespace, vaultSkipTLSVerify, jwt)
if err != nil {
return "", err
}

storeCachedToken(jwt, vaultToken, leaseDuration)
if logger != nil {
logger.Debug("Vault token obtained via JWT exchange")
}
return vaultToken, nil
}
Loading