feat: add bitwarden login flow with persisted BW_SESSION

This commit is contained in:
thibaud-lclr 2026-04-20 12:38:58 +02:00
parent 2920f5980a
commit 6e80d3418e
10 changed files with 673 additions and 22 deletions

View file

@ -12,6 +12,7 @@ import (
const ( const (
CommandSetup = "setup" CommandSetup = "setup"
CommandLogin = "login"
CommandMCP = "mcp" CommandMCP = "mcp"
CommandConfig = "config" CommandConfig = "config"
CommandUpdate = "update" CommandUpdate = "update"
@ -38,6 +39,7 @@ type Handler func(context.Context, Invocation) error
type Hooks struct { type Hooks struct {
Setup Handler Setup Handler
Login Handler
MCP Handler MCP Handler
Config Handler Config Handler
ConfigShow Handler ConfigShow Handler
@ -83,6 +85,13 @@ var commands = []commandDef{
return h.Setup return h.Setup
}, },
}, },
{
Name: CommandLogin,
Description: "Authentifier et deverrouiller Bitwarden pour persister BW_SESSION.",
Handler: func(h Hooks) Handler {
return h.Login
},
},
{ {
Name: CommandMCP, Name: CommandMCP,
Description: "Executer la logique MCP principale du binaire.", Description: "Executer la logique MCP principale du binaire.",

View file

@ -41,6 +41,37 @@ func TestRunRoutesSetupHook(t *testing.T) {
} }
} }
func TestRunRoutesLoginHook(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
var got Invocation
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Version: "v1.2.3",
Args: []string{"login", "--force"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{
Login: func(_ context.Context, inv Invocation) error {
got = inv
return nil
},
},
})
if err != nil {
t.Fatalf("Run error = %v", err)
}
if got.Command != CommandLogin {
t.Fatalf("invocation command = %q, want %q", got.Command, CommandLogin)
}
wantArgs := []string{"--force"}
if !slices.Equal(got.Args, wantArgs) {
t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs)
}
}
func TestRunReturnsUnknownCommand(t *testing.T) { func TestRunReturnsUnknownCommand(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@ -215,6 +246,29 @@ func TestRunPrintsCommandHelp(t *testing.T) {
} }
} }
func TestRunPrintsLoginCommandHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"help", "login"},
Stdout: &stdout,
Stderr: &stderr,
})
if err != nil {
t.Fatalf("Run error = %v", err)
}
text := stdout.String()
if !strings.Contains(text, "my-mcp login [args]") {
t.Fatalf("command help output = %q", text)
}
if !strings.Contains(strings.ToLower(text), "bitwarden") {
t.Fatalf("command help output missing bitwarden description: %q", text)
}
}
func TestRunPrintsConfigHelp(t *testing.T) { func TestRunPrintsConfigHelp(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer

View file

@ -18,6 +18,9 @@ func main() {
Setup: func(ctx context.Context, inv bootstrap.Invocation) error { Setup: func(ctx context.Context, inv bootstrap.Invocation) error {
return runSetup(ctx, inv.Args) return runSetup(ctx, inv.Args)
}, },
Login: func(ctx context.Context, inv bootstrap.Invocation) error {
return runLogin(ctx, inv.Args)
},
MCP: func(ctx context.Context, inv bootstrap.Invocation) error { MCP: func(ctx context.Context, inv bootstrap.Invocation) error {
return runMCP(ctx, inv.Args) return runMCP(ctx, inv.Args)
}, },
@ -41,6 +44,7 @@ func main() {
Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche automatiquement `Options.Version`. Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche automatiquement `Options.Version`.
Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`). Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`).
La commande `login` est optionnelle et peut être branchée pour gérer un unlock Bitwarden interactif.
La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`). La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`).
Les alias déclarés (ou `EnableDoctorAlias`) sont affichés dans l'aide globale. Les alias déclarés (ou `EnableDoctorAlias`) sont affichés dans l'aide globale.
Le flag global `--debug` est supporté et active le debug des appels Bitwarden Le flag global `--debug` est supporté et active le debug des appels Bitwarden

View file

@ -1,6 +1,6 @@
# Packages # Packages
- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites. - `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `login`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites.
- `cli` : helpers pour résoudre un profil, valider une URL, demander des valeurs à l'utilisateur et exécuter un `doctor`. - `cli` : helpers pour résoudre un profil, valider une URL, demander des valeurs à l'utilisateur et exécuter un `doctor`.
- `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`. - `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`.
- `manifest` : lecture de `mcp.toml` à la racine du projet, fallback embarqué pour le runtime, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. - `manifest` : lecture de `mcp.toml` à la racine du projet, fallback embarqué pour le runtime, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding.

View file

@ -91,6 +91,34 @@ if err := secretstore.EnsureBitwardenReady(secretstore.Options{
} }
``` ```
Pour lancer un flux interactif `bw login` / `bw unlock --raw`, récupérer `BW_SESSION`
et le persister localement (fichier `0600` sous le répertoire de config utilisateur) :
```go
session, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{
ServiceName: "email-mcp",
Stdin: os.Stdin,
Stdout: os.Stdout,
Stderr: os.Stderr,
})
if err != nil {
return err
}
fmt.Println("session chargée:", len(session) > 0)
```
Pour réinjecter automatiquement une session persistée dans l'environnement courant :
```go
loaded, err := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{
ServiceName: "email-mcp",
})
if err != nil {
return err
}
fmt.Println("session restaurée depuis disque:", loaded)
```
Pour stocker un secret structuré en JSON : Pour stocker un secret structuré en JSON :
```go ```go
@ -174,7 +202,7 @@ Les commandes `bw` exécutées seront affichées (avec redaction des payloads se
bw status bw status
``` ```
2. Déverrouiller le vault et exporter `BW_SESSION` : 2. Déverrouiller le vault et exporter `BW_SESSION` (ou utiliser `LoginBitwarden`) :
- Bash/Zsh : - Bash/Zsh :

View file

@ -1659,6 +1659,7 @@ func (r Runtime) Run(ctx context.Context, args []string) error {
Args: args, Args: args,
Hooks: bootstrap.Hooks{ Hooks: bootstrap.Hooks{
Setup: r.runSetup, Setup: r.runSetup,
Login: r.runLogin,
MCP: r.runMCP, MCP: r.runMCP,
ConfigShow: r.runConfigShow, ConfigShow: r.runConfigShow,
ConfigTest: r.runConfigTest, ConfigTest: r.runConfigTest,
@ -1751,6 +1752,42 @@ func (r Runtime) runSetup(_ context.Context, inv bootstrap.Invocation) error {
return err return err
} }
func (r Runtime) runLogin(_ context.Context, inv bootstrap.Invocation) error {
if r.activeBackendPolicy() != secretstore.BackendBitwardenCLI {
return fmt.Errorf(
"commande login disponible uniquement avec secret_store.backend_policy=%q",
secretstore.BackendBitwardenCLI,
)
}
stdin := inv.Stdin
if stdin == nil {
stdin = os.Stdin
}
stdout := inv.Stdout
if stdout == nil {
stdout = os.Stdout
}
stderr := inv.Stderr
if stderr == nil {
stderr = os.Stderr
}
if _, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{
ServiceName: r.BinaryName,
Stdin: stdin,
Stdout: stdout,
Stderr: stderr,
}); err != nil {
return err
}
_, err := fmt.Fprintf(stdout, "Session Bitwarden persistée pour %q.\n", r.BinaryName)
return err
}
func (r Runtime) runMCP(_ context.Context, inv bootstrap.Invocation) error { func (r Runtime) runMCP(_ context.Context, inv bootstrap.Invocation) error {
stdout := inv.Stdout stdout := inv.Stdout
if stdout == nil { if stdout == nil {
@ -1878,9 +1915,18 @@ func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error
} }
func (r Runtime) openSecretStore() (secretstore.Store, error) { func (r Runtime) openSecretStore() (secretstore.Store, error) {
policy := r.activeBackendPolicy()
if policy == secretstore.BackendBitwardenCLI {
if _, err := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{
ServiceName: r.BinaryName,
}); err != nil {
return nil, err
}
}
return secretstore.Open(secretstore.Options{ return secretstore.Open(secretstore.Options{
ServiceName: r.BinaryName, ServiceName: r.BinaryName,
BackendPolicy: r.activeBackendPolicy(), BackendPolicy: policy,
LookupEnv: func(name string) (string, bool) { LookupEnv: func(name string) (string, bool) {
if name == r.SecretName { if name == r.SecretName {
return os.LookupEnv(r.TokenEnv) return os.LookupEnv(r.TokenEnv)

View file

@ -67,6 +67,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
for _, snippet := range []string{ for _, snippet := range []string{
"config.NewStore[Profile]", "config.NewStore[Profile]",
"secretstore.Open(secretstore.Options", "secretstore.Open(secretstore.Options",
"secretstore.EnsureBitwardenSessionEnv",
"secretstore.LoginBitwarden",
"update.Run", "update.Run",
"manifest.LoadDefaultOrEmbedded", "manifest.LoadDefaultOrEmbedded",
"bootstrap.Run", "bootstrap.Run",
@ -76,6 +78,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
"ManifestSource", "ManifestSource",
"ManifestCheck: r.manifestDoctorCheck()", "ManifestCheck: r.manifestDoctorCheck()",
"SecretBackendPolicy: r.activeBackendPolicy()", "SecretBackendPolicy: r.activeBackendPolicy()",
"Login: r.runLogin,",
"func (r Runtime) runLogin(_ context.Context, inv bootstrap.Invocation) error {",
"secretStoreFactory := memoizeSecretStoreFactory(r.openSecretStore)", "secretStoreFactory := memoizeSecretStoreFactory(r.openSecretStore)",
"func memoizeSecretStoreFactory(factory func() (secretstore.Store, error)) func() (secretstore.Store, error) {", "func memoizeSecretStoreFactory(factory func() (secretstore.Store, error)) func() (secretstore.Store, error) {",
"cli.WriteSetupSecretVerified", "cli.WriteSetupSecretVerified",

View file

@ -33,8 +33,15 @@ const (
) )
type bitwardenRunner func(command string, stdin []byte, args ...string) ([]byte, error) type bitwardenRunner func(command string, stdin []byte, args ...string) ([]byte, error)
type bitwardenInteractiveRunner func(
command string,
stdin io.Reader,
stdout, stderr io.Writer,
args ...string,
) ([]byte, error)
var runBitwardenCLI bitwardenRunner = executeBitwardenCLI var runBitwardenCLI bitwardenRunner = executeBitwardenCLI
var runBitwardenInteractiveCLI bitwardenInteractiveRunner = executeBitwardenCLIInteractive
var bitwardenLoaderActive atomic.Bool var bitwardenLoaderActive atomic.Bool
var bitwardenDebugOutput io.Writer = os.Stderr var bitwardenDebugOutput io.Writer = os.Stderr
@ -66,6 +73,17 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string
debug: debugEnabled, debug: debugEnabled,
} }
if _, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{
ServiceName: serviceName,
}); err != nil {
return nil, fmt.Errorf(
"secret backend policy %q cannot load persisted bitwarden session for service %q: %w",
policy,
serviceName,
errors.Join(ErrBackendUnavailable, err),
)
}
if _, err := store.execute("verify bitwarden CLI availability", nil, "--version"); err != nil { if _, err := store.execute("verify bitwarden CLI availability", nil, "--version"); err != nil {
if errors.Is(err, exec.ErrNotFound) { if errors.Is(err, exec.ErrNotFound) {
return nil, fmt.Errorf( return nil, fmt.Errorf(
@ -109,27 +127,12 @@ func EnsureBitwardenReady(options Options) error {
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
unlockCommand := bitwardenUnlockRemediation(command, options.Shell) unlockCommand := bitwardenUnlockRemediation(command, options.Shell)
lookupEnv := options.LookupEnv status, err := readBitwardenStatus(command, debugEnabled)
if lookupEnv == nil {
lookupEnv = os.LookupEnv
}
output, err := runBitwardenCommand(command, debugEnabled, nil, "status")
if err != nil { if err != nil {
return fmt.Errorf("check bitwarden CLI status: %w", err) return err
} }
trimmed := strings.TrimSpace(string(output)) switch status {
if trimmed == "" {
return fmt.Errorf("%w: bitwarden CLI returned an empty status", ErrBWUnavailable)
}
var status bitwardenStatusOutput
if err := json.Unmarshal([]byte(trimmed), &status); err != nil {
return fmt.Errorf("decode bitwarden CLI status: %w", errors.Join(ErrBWUnavailable, err))
}
switch strings.ToLower(strings.TrimSpace(status.Status)) {
case "unauthenticated": case "unauthenticated":
return fmt.Errorf("%w: run `bw login` then retry", ErrBWNotLoggedIn) return fmt.Errorf("%w: run `bw login` then retry", ErrBWNotLoggedIn)
case "locked": case "locked":
@ -139,7 +142,15 @@ func EnsureBitwardenReady(options Options) error {
unlockCommand, unlockCommand,
) )
case "unlocked": case "unlocked":
lookupEnv := options.LookupEnv
if lookupEnv == nil {
lookupEnv = os.LookupEnv
}
session, ok := lookupEnv(bitwardenSessionEnvName) session, ok := lookupEnv(bitwardenSessionEnvName)
if !ok || strings.TrimSpace(session) == "" {
session, ok = os.LookupEnv(bitwardenSessionEnvName)
}
if !ok || strings.TrimSpace(session) == "" { if !ok || strings.TrimSpace(session) == "" {
return fmt.Errorf( return fmt.Errorf(
"%w: environment variable %q is missing; run `%s` then retry", "%w: environment variable %q is missing; run `%s` then retry",
@ -153,11 +164,30 @@ func EnsureBitwardenReady(options Options) error {
return fmt.Errorf( return fmt.Errorf(
"%w: unsupported bitwarden status %q", "%w: unsupported bitwarden status %q",
ErrBWUnavailable, ErrBWUnavailable,
strings.TrimSpace(status.Status), status,
) )
} }
} }
func readBitwardenStatus(command string, debug bool) (string, error) {
output, err := runBitwardenCommand(command, debug, nil, "status")
if err != nil {
return "", fmt.Errorf("check bitwarden CLI status: %w", err)
}
trimmed := strings.TrimSpace(string(output))
if trimmed == "" {
return "", fmt.Errorf("%w: bitwarden CLI returned an empty status", ErrBWUnavailable)
}
var status bitwardenStatusOutput
if err := json.Unmarshal([]byte(trimmed), &status); err != nil {
return "", fmt.Errorf("decode bitwarden CLI status: %w", errors.Join(ErrBWUnavailable, err))
}
return strings.ToLower(strings.TrimSpace(status.Status)), nil
}
func bitwardenUnlockRemediation(command, shellHint string) string { func bitwardenUnlockRemediation(command, shellHint string) string {
unlockCommand := fmt.Sprintf("%s unlock --raw", strings.TrimSpace(command)) unlockCommand := fmt.Sprintf("%s unlock --raw", strings.TrimSpace(command))
@ -536,6 +566,19 @@ func runBitwardenCommand(command string, debug bool, stdin []byte, args ...strin
return runBitwardenCLI(command, stdin, args...) return runBitwardenCLI(command, stdin, args...)
} }
func runBitwardenInteractiveCommand(
command string,
debug bool,
stdin io.Reader,
stdout, stderr io.Writer,
args ...string,
) ([]byte, error) {
if debug {
logBitwardenCommand(command, args...)
}
return runBitwardenInteractiveCLI(command, stdin, stdout, stderr, args...)
}
func isBitwardenDebugEnabled(explicit bool) bool { func isBitwardenDebugEnabled(explicit bool) bool {
if explicit { if explicit {
return true return true
@ -615,6 +658,41 @@ func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte,
return stdout.Bytes(), nil return stdout.Bytes(), nil
} }
func executeBitwardenCLIInteractive(
command string,
stdin io.Reader,
stdout, stderr io.Writer,
args ...string,
) ([]byte, error) {
stopLoader := startBitwardenLoader()
defer stopLoader()
cmd := exec.Command(command, args...)
if stdin != nil {
cmd.Stdin = stdin
}
var stdoutBuffer bytes.Buffer
if stdout == nil {
cmd.Stdout = &stdoutBuffer
} else {
cmd.Stdout = io.MultiWriter(stdout, &stdoutBuffer)
}
var stderrBuffer bytes.Buffer
if stderr == nil {
cmd.Stderr = &stderrBuffer
} else {
cmd.Stderr = io.MultiWriter(stderr, &stderrBuffer)
}
if err := cmd.Run(); err != nil {
return nil, normalizeBitwardenExecutionError(err, stderrBuffer.String(), stdoutBuffer.String())
}
return stdoutBuffer.Bytes(), nil
}
func startBitwardenLoader() func() { func startBitwardenLoader() func() {
if !shouldShowBitwardenLoader() { if !shouldShowBitwardenLoader() {
return func() {} return func() {}

View file

@ -0,0 +1,197 @@
package secretstore
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
const bitwardenSessionFileName = "bw-session"
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 "locked", "unlocked":
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)
}
return session, nil
}
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
}

View file

@ -0,0 +1,231 @@
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 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
})
}