mcp-framework/secretstore/bitwarden_session.go
thibaud-lclr 90dbed4d37 fix(secretstore): éviter l'invalidation croisée des sessions Bitwarden entre MCPs
Quand deux MCPs appellaient login, le second appelait bw unlock et générait
un nouveau token, invalidant celui du premier. Deux mécanismes corrigent ça :

1. LoginBitwarden ne relance plus bw unlock si le vault est déjà unlocked
   et qu'une session existe (env, fichier service, ou fichier partagé).
2. Le login écrit le token dans ~/.config/mcp-framework/bw-session (partagé)
   en plus du fichier service-spécifique. Les autres MCPs lisent ce fichier
   en priorité via refreshSessionEnv avant chaque opération Bitwarden.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:57:58 +02:00

234 lines
6.9 KiB
Go

package secretstore
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
const (
bitwardenSessionFileName = "bw-session"
bitwardenSharedSessionName = "mcp-framework"
)
var bitwardenUserConfigDir = os.UserConfigDir
type BitwardenSessionOptions struct {
ServiceName string
}
type BitwardenLoginOptions struct {
ServiceName string
BitwardenCommand string
BitwardenDebug bool
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
}
func SaveBitwardenSession(options BitwardenSessionOptions, session string) (string, error) {
trimmedSession := strings.TrimSpace(session)
if trimmedSession == "" {
return "", errors.New("bitwarden session must not be empty")
}
path, err := resolveBitwardenSessionPath(options)
if err != nil {
return "", err
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("create bitwarden session dir %q: %w", dir, err)
}
if err := os.Chmod(dir, 0o700); err != nil {
return "", fmt.Errorf("set bitwarden session dir permissions %q: %w", dir, err)
}
tmpFile, err := os.CreateTemp(dir, "bw-session-*.tmp")
if err != nil {
return "", fmt.Errorf("create temp bitwarden session in %q: %w", dir, err)
}
tmpPath := tmpFile.Name()
cleanup := true
defer func() {
_ = tmpFile.Close()
if cleanup {
_ = os.Remove(tmpPath)
}
}()
if err := tmpFile.Chmod(0o600); err != nil {
return "", fmt.Errorf("set bitwarden session temp file permissions %q: %w", tmpPath, err)
}
if _, err := tmpFile.WriteString(trimmedSession + "\n"); err != nil {
return "", fmt.Errorf("write bitwarden session temp file %q: %w", tmpPath, err)
}
if err := tmpFile.Close(); err != nil {
return "", fmt.Errorf("close bitwarden session temp file %q: %w", tmpPath, err)
}
if err := os.Rename(tmpPath, path); err != nil {
return "", fmt.Errorf("replace bitwarden session file %q: %w", path, err)
}
if err := os.Chmod(path, 0o600); err != nil {
return "", fmt.Errorf("set bitwarden session file permissions %q: %w", path, err)
}
cleanup = false
return path, nil
}
func LoadBitwardenSession(options BitwardenSessionOptions) (string, error) {
path, err := resolveBitwardenSessionPath(options)
if err != nil {
return "", err
}
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", ErrNotFound
}
return "", fmt.Errorf("read bitwarden session file %q: %w", path, err)
}
session := strings.TrimSpace(string(data))
if session == "" {
return "", ErrNotFound
}
return session, nil
}
func EnsureBitwardenSessionEnv(options BitwardenSessionOptions) (bool, error) {
if session, ok := os.LookupEnv(bitwardenSessionEnvName); ok && strings.TrimSpace(session) != "" {
return false, nil
}
session, err := LoadBitwardenSession(options)
if err != nil {
if errors.Is(err, ErrNotFound) {
return false, nil
}
return false, err
}
if err := os.Setenv(bitwardenSessionEnvName, session); err != nil {
return false, fmt.Errorf("set %s from persisted bitwarden session: %w", bitwardenSessionEnvName, err)
}
return true, nil
}
func LoginBitwarden(options BitwardenLoginOptions) (string, error) {
serviceName := strings.TrimSpace(options.ServiceName)
if serviceName == "" {
return "", errors.New("service name must not be empty")
}
command := strings.TrimSpace(options.BitwardenCommand)
if command == "" {
command = defaultBitwardenCommand
}
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
stdin := options.Stdin
if stdin == nil {
stdin = os.Stdin
}
stdout := options.Stdout
if stdout == nil {
stdout = os.Stdout
}
stderr := options.Stderr
if stderr == nil {
stderr = os.Stderr
}
status, err := readBitwardenStatus(command, debugEnabled)
if err != nil {
return "", err
}
switch status {
case "unauthenticated":
if _, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, stdout, stderr, "login"); err != nil {
return "", fmt.Errorf("login to bitwarden CLI: %w", err)
}
case "unlocked":
// Vault is already unlocked. Reuse an existing session to avoid calling
// bw unlock again, which would generate a new token and invalidate the
// tokens held by other running MCP processes.
if existing := loadAnyBitwardenSession(serviceName); existing != "" {
if err := os.Setenv(bitwardenSessionEnvName, existing); err != nil {
return "", fmt.Errorf("set %s from existing session: %w", bitwardenSessionEnvName, err)
}
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}, existing); err != nil {
return "", fmt.Errorf("persist bitwarden session: %w", err)
}
return existing, nil
}
case "locked":
default:
return "", fmt.Errorf("%w: unsupported bitwarden status %q", ErrBWUnavailable, status)
}
unlockOutput, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, nil, stderr, "unlock", "--raw")
if err != nil {
return "", fmt.Errorf("unlock bitwarden vault: %w", err)
}
session := strings.TrimSpace(string(unlockOutput))
if session == "" {
return "", errors.New("bitwarden CLI returned an empty session after unlock")
}
if err := os.Setenv(bitwardenSessionEnvName, session); err != nil {
return "", fmt.Errorf("set %s after bitwarden unlock: %w", bitwardenSessionEnvName, err)
}
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}, session); err != nil {
return "", fmt.Errorf("persist bitwarden session: %w", err)
}
// Save to shared file so other MCP processes can reuse this session
// without needing to call bw unlock themselves.
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, session); err != nil {
return "", fmt.Errorf("persist shared bitwarden session: %w", err)
}
return session, nil
}
// loadAnyBitwardenSession looks for an existing valid session in: the process
// environment, the service-specific file, and the shared file written by any
// MCP login. Returns the first non-empty session found.
func loadAnyBitwardenSession(serviceName string) string {
if session, ok := os.LookupEnv(bitwardenSessionEnvName); ok && strings.TrimSpace(session) != "" {
return strings.TrimSpace(session)
}
if session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}); err == nil {
return session
}
if session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}); err == nil {
return session
}
return ""
}
func resolveBitwardenSessionPath(options BitwardenSessionOptions) (string, error) {
serviceName := strings.TrimSpace(options.ServiceName)
if serviceName == "" {
return "", errors.New("service name must not be empty")
}
userConfigDir, err := bitwardenUserConfigDir()
if err != nil {
return "", fmt.Errorf("resolve user config dir for bitwarden session: %w", err)
}
return filepath.Join(userConfigDir, serviceName, bitwardenSessionFileName), nil
}