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>
This commit is contained in:
thibaud-lclr 2026-05-13 12:30:58 +02:00
parent 078aa17285
commit 90dbed4d37
4 changed files with 156 additions and 6 deletions

View file

@ -12,7 +12,7 @@
### Corrections ### Corrections
- **Secretstore — rafraîchissement automatique de la session Bitwarden** : chaque MCP relit désormais le fichier de session avant toute opération Bitwarden. Cela résout le cas où débloquer le vault depuis un MCP rendait les autres inopérants (session obsolète ou absente de l'environnement). - **Secretstore — isolation des sessions Bitwarden entre MCPs** : `bw unlock` n'est plus appelé si le vault est déjà ouvert et qu'une session valide existe (env, fichier service, ou fichier partagé). Le login écrit désormais dans un fichier partagé (`~/.config/mcp-framework/bw-session`) que les autres MCPs lisent en priorité, évitant de générer un nouveau token qui invaliderait les sessions en cours. Chaque MCP relit aussi ce fichier avant chaque opération Bitwarden pour récupérer dynamiquement les sessions créées après son démarrage.
### Changements cassants ### Changements cassants

View file

@ -394,11 +394,18 @@ func (s *bitwardenStore) ensureReady() error {
} }
func (s *bitwardenStore) refreshSessionEnv() { func (s *bitwardenStore) refreshSessionEnv() {
session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: s.serviceName}) // Prefer the shared file: it holds the latest session from any MCP login.
if err != nil || strings.TrimSpace(session) == "" { if session, err := LoadBitwardenSession(BitwardenSessionOptions{
ServiceName: bitwardenSharedSessionName,
}); err == nil && strings.TrimSpace(session) != "" {
_ = os.Setenv(bitwardenSessionEnvName, strings.TrimSpace(session))
return return
} }
_ = os.Setenv(bitwardenSessionEnvName, strings.TrimSpace(session)) if session, err := LoadBitwardenSession(BitwardenSessionOptions{
ServiceName: s.serviceName,
}); err == nil && strings.TrimSpace(session) != "" {
_ = os.Setenv(bitwardenSessionEnvName, strings.TrimSpace(session))
}
} }
type bitwardenResolvedItem struct { type bitwardenResolvedItem struct {

View file

@ -9,7 +9,10 @@ import (
"strings" "strings"
) )
const bitwardenSessionFileName = "bw-session" const (
bitwardenSessionFileName = "bw-session"
bitwardenSharedSessionName = "mcp-framework"
)
var bitwardenUserConfigDir = os.UserConfigDir var bitwardenUserConfigDir = os.UserConfigDir
@ -156,7 +159,20 @@ func LoginBitwarden(options BitwardenLoginOptions) (string, error) {
if _, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, stdout, stderr, "login"); err != nil { if _, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, stdout, stderr, "login"); err != nil {
return "", fmt.Errorf("login to bitwarden CLI: %w", err) return "", fmt.Errorf("login to bitwarden CLI: %w", err)
} }
case "locked", "unlocked": 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: default:
return "", fmt.Errorf("%w: unsupported bitwarden status %q", ErrBWUnavailable, status) return "", fmt.Errorf("%w: unsupported bitwarden status %q", ErrBWUnavailable, status)
} }
@ -178,10 +194,31 @@ func LoginBitwarden(options BitwardenLoginOptions) (string, error) {
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}, session); err != nil { if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}, session); err != nil {
return "", fmt.Errorf("persist bitwarden session: %w", err) 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 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) { func resolveBitwardenSessionPath(options BitwardenSessionOptions) (string, error) {
serviceName := strings.TrimSpace(options.ServiceName) serviceName := strings.TrimSpace(options.ServiceName)
if serviceName == "" { if serviceName == "" {

View file

@ -207,6 +207,112 @@ func TestLoadBitwardenSessionReturnsNotFoundWhenFileMissing(t *testing.T) {
} }
} }
func TestLoginBitwardenPersistsToSharedFileAfterUnlock(t *testing.T) {
t.Setenv("BW_SESSION", "")
configDir := t.TempDir()
withBitwardenUserConfigDir(t, func() (string, error) {
return configDir, nil
})
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
if len(args) == 1 && args[0] == "status" {
return []byte(`{"status":"locked"}`), nil
}
return nil, fmt.Errorf("unexpected args: %v", args)
})
withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) {
if len(args) == 2 && args[0] == "unlock" && args[1] == "--raw" {
return []byte("shared-session\n"), nil
}
return nil, fmt.Errorf("unexpected interactive args: %v", args)
})
if _, err := LoginBitwarden(BitwardenLoginOptions{
ServiceName: "graylog-mcp",
BitwardenCommand: "bw",
}); err != nil {
t.Fatalf("LoginBitwarden returned error: %v", err)
}
shared, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName})
if err != nil {
t.Fatalf("LoadBitwardenSession (shared) returned error: %v", err)
}
if shared != "shared-session" {
t.Fatalf("shared session = %q, want shared-session", shared)
}
}
func TestLoginBitwardenSkipsUnlockWhenAlreadyUnlockedWithEnvSession(t *testing.T) {
t.Setenv("BW_SESSION", "existing-session")
configDir := t.TempDir()
withBitwardenUserConfigDir(t, func() (string, error) {
return configDir, nil
})
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
if len(args) == 1 && args[0] == "status" {
return []byte(`{"status":"unlocked"}`), nil
}
return nil, fmt.Errorf("unexpected args: %v", args)
})
withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) {
return nil, fmt.Errorf("bw unlock must not be called when vault is already unlocked with a session: %v", args)
})
session, err := LoginBitwarden(BitwardenLoginOptions{
ServiceName: "email-mcp",
BitwardenCommand: "bw",
})
if err != nil {
t.Fatalf("LoginBitwarden returned error: %v", err)
}
if session != "existing-session" {
t.Fatalf("session = %q, want existing-session", session)
}
}
func TestLoginBitwardenSkipsUnlockWhenAlreadyUnlockedWithSharedSession(t *testing.T) {
t.Setenv("BW_SESSION", "")
configDir := t.TempDir()
withBitwardenUserConfigDir(t, func() (string, error) {
return configDir, nil
})
// graylog-mcp logged in earlier and wrote to the shared file
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, "graylog-session"); err != nil {
t.Fatalf("SaveBitwardenSession returned error: %v", err)
}
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
if len(args) == 1 && args[0] == "status" {
return []byte(`{"status":"unlocked"}`), nil
}
return nil, fmt.Errorf("unexpected args: %v", args)
})
withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) {
return nil, fmt.Errorf("bw unlock must not be called when shared session exists: %v", args)
})
session, err := LoginBitwarden(BitwardenLoginOptions{
ServiceName: "email-mcp",
BitwardenCommand: "bw",
})
if err != nil {
t.Fatalf("LoginBitwarden returned error: %v", err)
}
if session != "graylog-session" {
t.Fatalf("session = %q, want graylog-session (from shared file)", session)
}
// The shared session must also be persisted to the service-specific file
persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"})
if err != nil {
t.Fatalf("LoadBitwardenSession returned error: %v", err)
}
if persisted != "graylog-session" {
t.Fatalf("email-mcp persisted session = %q, want graylog-session", persisted)
}
}
func withBitwardenInteractiveRunner( func withBitwardenInteractiveRunner(
t *testing.T, t *testing.T,
runner func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error), runner func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error),