mcp-framework/secretstore/bitwarden_session.go

230 lines
6.5 KiB
Go
Raw Normal View History

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, err
}
// Service-specific file not found; try the shared file written by any MCP login.
session, err = LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName})
if err != nil {
return false, nil
}
}
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(); 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: bitwardenSharedSessionName}, session); err != nil {
return "", fmt.Errorf("persist bitwarden session: %w", err)
}
return session, nil
}
// loadAnyBitwardenSession looks for an existing valid session in: the process
// environment, then the shared file written by any MCP login.
func loadAnyBitwardenSession() string {
if session, ok := os.LookupEnv(bitwardenSessionEnvName); ok && strings.TrimSpace(session) != "" {
return strings.TrimSpace(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
}