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 }