feat: add bitwarden login flow with persisted BW_SESSION
This commit is contained in:
parent
2920f5980a
commit
6e80d3418e
10 changed files with 673 additions and 22 deletions
|
|
@ -12,6 +12,7 @@ import (
|
|||
|
||||
const (
|
||||
CommandSetup = "setup"
|
||||
CommandLogin = "login"
|
||||
CommandMCP = "mcp"
|
||||
CommandConfig = "config"
|
||||
CommandUpdate = "update"
|
||||
|
|
@ -38,6 +39,7 @@ type Handler func(context.Context, Invocation) error
|
|||
|
||||
type Hooks struct {
|
||||
Setup Handler
|
||||
Login Handler
|
||||
MCP Handler
|
||||
Config Handler
|
||||
ConfigShow Handler
|
||||
|
|
@ -83,6 +85,13 @@ var commands = []commandDef{
|
|||
return h.Setup
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: CommandLogin,
|
||||
Description: "Authentifier et deverrouiller Bitwarden pour persister BW_SESSION.",
|
||||
Handler: func(h Hooks) Handler {
|
||||
return h.Login
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: CommandMCP,
|
||||
Description: "Executer la logique MCP principale du binaire.",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
var stdout 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) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ func main() {
|
|||
Setup: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||
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 {
|
||||
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`.
|
||||
|
||||
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`).
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 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`.
|
||||
- `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.
|
||||
|
|
|
|||
|
|
@ -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 :
|
||||
|
||||
```go
|
||||
|
|
@ -174,7 +202,7 @@ Les commandes `bw` exécutées seront affichées (avec redaction des payloads se
|
|||
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 :
|
||||
|
||||
|
|
|
|||
|
|
@ -1659,6 +1659,7 @@ func (r Runtime) Run(ctx context.Context, args []string) error {
|
|||
Args: args,
|
||||
Hooks: bootstrap.Hooks{
|
||||
Setup: r.runSetup,
|
||||
Login: r.runLogin,
|
||||
MCP: r.runMCP,
|
||||
ConfigShow: r.runConfigShow,
|
||||
ConfigTest: r.runConfigTest,
|
||||
|
|
@ -1751,6 +1752,42 @@ func (r Runtime) runSetup(_ context.Context, inv bootstrap.Invocation) error {
|
|||
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 {
|
||||
stdout := inv.Stdout
|
||||
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) {
|
||||
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{
|
||||
ServiceName: r.BinaryName,
|
||||
BackendPolicy: r.activeBackendPolicy(),
|
||||
BackendPolicy: policy,
|
||||
LookupEnv: func(name string) (string, bool) {
|
||||
if name == r.SecretName {
|
||||
return os.LookupEnv(r.TokenEnv)
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
|
|||
for _, snippet := range []string{
|
||||
"config.NewStore[Profile]",
|
||||
"secretstore.Open(secretstore.Options",
|
||||
"secretstore.EnsureBitwardenSessionEnv",
|
||||
"secretstore.LoginBitwarden",
|
||||
"update.Run",
|
||||
"manifest.LoadDefaultOrEmbedded",
|
||||
"bootstrap.Run",
|
||||
|
|
@ -76,6 +78,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
|
|||
"ManifestSource",
|
||||
"ManifestCheck: r.manifestDoctorCheck()",
|
||||
"SecretBackendPolicy: r.activeBackendPolicy()",
|
||||
"Login: r.runLogin,",
|
||||
"func (r Runtime) runLogin(_ context.Context, inv bootstrap.Invocation) error {",
|
||||
"secretStoreFactory := memoizeSecretStoreFactory(r.openSecretStore)",
|
||||
"func memoizeSecretStoreFactory(factory func() (secretstore.Store, error)) func() (secretstore.Store, error) {",
|
||||
"cli.WriteSetupSecretVerified",
|
||||
|
|
|
|||
|
|
@ -33,8 +33,15 @@ const (
|
|||
)
|
||||
|
||||
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 runBitwardenInteractiveCLI bitwardenInteractiveRunner = executeBitwardenCLIInteractive
|
||||
var bitwardenLoaderActive atomic.Bool
|
||||
var bitwardenDebugOutput io.Writer = os.Stderr
|
||||
|
||||
|
|
@ -66,6 +73,17 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string
|
|||
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 errors.Is(err, exec.ErrNotFound) {
|
||||
return nil, fmt.Errorf(
|
||||
|
|
@ -109,27 +127,12 @@ func EnsureBitwardenReady(options Options) error {
|
|||
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
|
||||
unlockCommand := bitwardenUnlockRemediation(command, options.Shell)
|
||||
|
||||
lookupEnv := options.LookupEnv
|
||||
if lookupEnv == nil {
|
||||
lookupEnv = os.LookupEnv
|
||||
}
|
||||
|
||||
output, err := runBitwardenCommand(command, debugEnabled, nil, "status")
|
||||
status, err := readBitwardenStatus(command, debugEnabled)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check bitwarden CLI status: %w", err)
|
||||
return 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))
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(status.Status)) {
|
||||
switch status {
|
||||
case "unauthenticated":
|
||||
return fmt.Errorf("%w: run `bw login` then retry", ErrBWNotLoggedIn)
|
||||
case "locked":
|
||||
|
|
@ -139,7 +142,15 @@ func EnsureBitwardenReady(options Options) error {
|
|||
unlockCommand,
|
||||
)
|
||||
case "unlocked":
|
||||
lookupEnv := options.LookupEnv
|
||||
if lookupEnv == nil {
|
||||
lookupEnv = os.LookupEnv
|
||||
}
|
||||
|
||||
session, ok := lookupEnv(bitwardenSessionEnvName)
|
||||
if !ok || strings.TrimSpace(session) == "" {
|
||||
session, ok = os.LookupEnv(bitwardenSessionEnvName)
|
||||
}
|
||||
if !ok || strings.TrimSpace(session) == "" {
|
||||
return fmt.Errorf(
|
||||
"%w: environment variable %q is missing; run `%s` then retry",
|
||||
|
|
@ -153,11 +164,30 @@ func EnsureBitwardenReady(options Options) error {
|
|||
return fmt.Errorf(
|
||||
"%w: unsupported bitwarden status %q",
|
||||
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 {
|
||||
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...)
|
||||
}
|
||||
|
||||
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 {
|
||||
if explicit {
|
||||
return true
|
||||
|
|
@ -615,6 +658,41 @@ func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte,
|
|||
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() {
|
||||
if !shouldShowBitwardenLoader() {
|
||||
return func() {}
|
||||
|
|
|
|||
197
secretstore/bitwarden_session.go
Normal file
197
secretstore/bitwarden_session.go
Normal 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
|
||||
}
|
||||
231
secretstore/bitwarden_session_test.go
Normal file
231
secretstore/bitwarden_session_test.go
Normal 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
|
||||
})
|
||||
}
|
||||
Loading…
Reference in a new issue