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>
337 lines
11 KiB
Go
337 lines
11 KiB
Go
package secretstore
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"slices"
|
|
"testing"
|
|
)
|
|
|
|
func TestLoginBitwardenRunsInteractiveFlowAndPersistsSession(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":"unauthenticated"}`), nil
|
|
}
|
|
return nil, fmt.Errorf("unexpected args: %v", args)
|
|
})
|
|
|
|
var calls [][]string
|
|
withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) {
|
|
calls = append(calls, slices.Clone(args))
|
|
switch {
|
|
case len(args) == 1 && args[0] == "login":
|
|
return nil, nil
|
|
case len(args) == 2 && args[0] == "unlock" && args[1] == "--raw":
|
|
return []byte("persisted-session\n"), nil
|
|
default:
|
|
return nil, fmt.Errorf("unexpected interactive args: %v", args)
|
|
}
|
|
})
|
|
|
|
session, err := LoginBitwarden(BitwardenLoginOptions{
|
|
ServiceName: "email-mcp",
|
|
BitwardenCommand: "bw",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("LoginBitwarden returned error: %v", err)
|
|
}
|
|
if session != "persisted-session" {
|
|
t.Fatalf("session = %q, want persisted-session", session)
|
|
}
|
|
if got := os.Getenv("BW_SESSION"); got != "persisted-session" {
|
|
t.Fatalf("BW_SESSION = %q, want persisted-session", got)
|
|
}
|
|
|
|
if len(calls) != 2 {
|
|
t.Fatalf("interactive call count = %d, want 2", len(calls))
|
|
}
|
|
if !slices.Equal(calls[0], []string{"login"}) {
|
|
t.Fatalf("interactive call #1 args = %v, want [login]", calls[0])
|
|
}
|
|
if !slices.Equal(calls[1], []string{"unlock", "--raw"}) {
|
|
t.Fatalf("interactive call #2 args = %v, want [unlock --raw]", calls[1])
|
|
}
|
|
|
|
persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"})
|
|
if err != nil {
|
|
t.Fatalf("LoadBitwardenSession returned error: %v", err)
|
|
}
|
|
if persisted != "persisted-session" {
|
|
t.Fatalf("persisted session = %q, want persisted-session", persisted)
|
|
}
|
|
}
|
|
|
|
func TestLoginBitwardenSkipsInteractiveLoginWhenAlreadyAuthenticated(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)
|
|
})
|
|
|
|
var calls [][]string
|
|
withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) {
|
|
calls = append(calls, slices.Clone(args))
|
|
if len(args) == 2 && args[0] == "unlock" && args[1] == "--raw" {
|
|
return []byte("session-locked\n"), nil
|
|
}
|
|
return nil, fmt.Errorf("unexpected interactive args: %v", args)
|
|
})
|
|
|
|
session, err := LoginBitwarden(BitwardenLoginOptions{
|
|
ServiceName: "email-mcp",
|
|
BitwardenCommand: "bw",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("LoginBitwarden returned error: %v", err)
|
|
}
|
|
if session != "session-locked" {
|
|
t.Fatalf("session = %q, want session-locked", session)
|
|
}
|
|
|
|
if len(calls) != 1 {
|
|
t.Fatalf("interactive call count = %d, want 1", len(calls))
|
|
}
|
|
if !slices.Equal(calls[0], []string{"unlock", "--raw"}) {
|
|
t.Fatalf("interactive call args = %v, want [unlock --raw]", calls[0])
|
|
}
|
|
}
|
|
|
|
func TestBitwardenSessionEnvLoadsFromPersistedSession(t *testing.T) {
|
|
t.Setenv("BW_SESSION", "")
|
|
configDir := t.TempDir()
|
|
withBitwardenUserConfigDir(t, func() (string, error) {
|
|
return configDir, nil
|
|
})
|
|
|
|
path, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "persisted-session")
|
|
if err != nil {
|
|
t.Fatalf("SaveBitwardenSession returned error: %v", err)
|
|
}
|
|
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatalf("Stat persisted session file: %v", err)
|
|
}
|
|
if got := info.Mode().Perm(); got != 0o600 {
|
|
t.Fatalf("session file mode = %o, want 600", got)
|
|
}
|
|
|
|
loaded, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{ServiceName: "email-mcp"})
|
|
if err != nil {
|
|
t.Fatalf("EnsureBitwardenSessionEnv returned error: %v", err)
|
|
}
|
|
if !loaded {
|
|
t.Fatal("expected session to be loaded from persisted file")
|
|
}
|
|
if got := os.Getenv("BW_SESSION"); got != "persisted-session" {
|
|
t.Fatalf("BW_SESSION = %q, want persisted-session", got)
|
|
}
|
|
}
|
|
|
|
func TestBitwardenSessionEnvDoesNotOverrideExistingValue(t *testing.T) {
|
|
t.Setenv("BW_SESSION", "from-env")
|
|
configDir := t.TempDir()
|
|
withBitwardenUserConfigDir(t, func() (string, error) {
|
|
return configDir, nil
|
|
})
|
|
|
|
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "persisted-session"); err != nil {
|
|
t.Fatalf("SaveBitwardenSession returned error: %v", err)
|
|
}
|
|
|
|
loaded, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{ServiceName: "email-mcp"})
|
|
if err != nil {
|
|
t.Fatalf("EnsureBitwardenSessionEnv returned error: %v", err)
|
|
}
|
|
if loaded {
|
|
t.Fatal("expected existing BW_SESSION to be kept")
|
|
}
|
|
if got := os.Getenv("BW_SESSION"); got != "from-env" {
|
|
t.Fatalf("BW_SESSION = %q, want from-env", got)
|
|
}
|
|
}
|
|
|
|
func TestOpenBitwardenCLILoadsPersistedSessionWhenEnvironmentIsMissing(t *testing.T) {
|
|
t.Setenv("BW_SESSION", "")
|
|
configDir := t.TempDir()
|
|
withBitwardenUserConfigDir(t, func() (string, error) {
|
|
return configDir, nil
|
|
})
|
|
|
|
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "persisted-open-session"); err != nil {
|
|
t.Fatalf("SaveBitwardenSession returned error: %v", err)
|
|
}
|
|
|
|
fakeCLI := newFakeBitwardenCLI("bw")
|
|
withBitwardenRunner(t, fakeCLI.run)
|
|
|
|
store, err := Open(Options{
|
|
ServiceName: "email-mcp",
|
|
BackendPolicy: BackendBitwardenCLI,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Open returned error: %v", err)
|
|
}
|
|
if _, ok := store.(*bitwardenStore); !ok {
|
|
t.Fatalf("store type = %T, want *bitwardenStore", store)
|
|
}
|
|
if got := os.Getenv("BW_SESSION"); got != "persisted-open-session" {
|
|
t.Fatalf("BW_SESSION = %q, want persisted-open-session", got)
|
|
}
|
|
}
|
|
|
|
func TestLoadBitwardenSessionReturnsNotFoundWhenFileMissing(t *testing.T) {
|
|
configDir := t.TempDir()
|
|
withBitwardenUserConfigDir(t, func() (string, error) {
|
|
return configDir, nil
|
|
})
|
|
|
|
_, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"})
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("error = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
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(
|
|
t *testing.T,
|
|
runner func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error),
|
|
) {
|
|
t.Helper()
|
|
|
|
previous := runBitwardenInteractiveCLI
|
|
runBitwardenInteractiveCLI = runner
|
|
t.Cleanup(func() {
|
|
runBitwardenInteractiveCLI = previous
|
|
})
|
|
}
|
|
|
|
func withBitwardenUserConfigDir(t *testing.T, resolver func() (string, error)) {
|
|
t.Helper()
|
|
|
|
previous := bitwardenUserConfigDir
|
|
bitwardenUserConfigDir = resolver
|
|
t.Cleanup(func() {
|
|
bitwardenUserConfigDir = previous
|
|
})
|
|
}
|