mirror of
https://github.com/foomo/posh-providers.git
synced 2025-10-16 12:35:41 +00:00
439 lines
12 KiB
Go
439 lines
12 KiB
Go
package onepassword
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"text/template"
|
|
"time"
|
|
|
|
"github.com/1Password/connect-sdk-go/connect"
|
|
"github.com/1Password/connect-sdk-go/onepassword"
|
|
"github.com/foomo/posh/pkg/cache"
|
|
"github.com/foomo/posh/pkg/log"
|
|
"github.com/joho/godotenv"
|
|
"github.com/pkg/errors"
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
type (
|
|
OnePassword struct {
|
|
l log.Logger
|
|
cfg Config
|
|
cache cache.Namespace
|
|
connect connect.Client
|
|
uuidRegex *regexp.Regexp
|
|
watching map[string]bool
|
|
configKey string
|
|
isSignedInLock sync.Mutex
|
|
isSignedInTime time.Time
|
|
}
|
|
Option func(*OnePassword) error
|
|
)
|
|
|
|
// ------------------------------------------------------------------------------------------------
|
|
// ~ Options
|
|
// ------------------------------------------------------------------------------------------------
|
|
|
|
func WithConfigKey(v string) Option {
|
|
return func(o *OnePassword) error {
|
|
o.configKey = v
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------------------------
|
|
// ~ Constructor
|
|
// ------------------------------------------------------------------------------------------------
|
|
|
|
func New(l log.Logger, cache cache.Cache, opts ...Option) (*OnePassword, error) {
|
|
inst := &OnePassword{
|
|
l: l.Named("onePasswordInstance"),
|
|
cache: cache.Get("onePasswordInstance"),
|
|
uuidRegex: regexp.MustCompile(`^[a-z0-9]{26}$`),
|
|
watching: map[string]bool{},
|
|
configKey: "onePassword",
|
|
}
|
|
for _, opt := range opts {
|
|
if opt != nil {
|
|
if err := opt(inst); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
if err := viper.UnmarshalKey(inst.configKey, &inst.cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
if client, err := connect.NewClientFromEnvironment(); err != nil {
|
|
l.Debug("connect client:", err.Error())
|
|
} else {
|
|
inst.connect = client
|
|
}
|
|
|
|
return inst, nil
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------------------------
|
|
// ~ Public methods
|
|
// ------------------------------------------------------------------------------------------------
|
|
|
|
func (op *OnePassword) IsAuthenticated() (bool, error) {
|
|
var sessChanged bool
|
|
sess := os.Getenv("OP_SESSION_" + op.cfg.Account)
|
|
op.isSignedInLock.Lock()
|
|
defer op.isSignedInLock.Unlock()
|
|
|
|
if op.cfg.TokenFilename != "" {
|
|
if err := godotenv.Overload(op.cfg.TokenFilename); err != nil {
|
|
op.l.Debug("could not load session from env file:", err.Error())
|
|
sessChanged = true
|
|
} else if value := os.Getenv("OP_SESSION_" + op.cfg.Account); sess != value {
|
|
op.l.Debug("loaded new op session from file:", op.cfg.TokenFilename)
|
|
sessChanged = true
|
|
} else {
|
|
op.l.Trace("loaded op session from file:", op.cfg.TokenFilename)
|
|
}
|
|
}
|
|
|
|
if sessChanged || op.isSignedInTime.IsZero() || time.Since(op.isSignedInTime) > time.Minute*10 {
|
|
out, err := exec.Command("op", "account", "--account", op.cfg.Account, "get", "--format", "json").Output()
|
|
if err != nil {
|
|
return false, fmt.Errorf("%w: %s", err, string(out))
|
|
}
|
|
|
|
var data struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
if err := json.Unmarshal(out, &data); err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if data.Name == op.cfg.Account {
|
|
op.isSignedInTime = time.Now()
|
|
op.watch()
|
|
return true, nil
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (op *OnePassword) SignIn(ctx context.Context) error {
|
|
if ok, _ := op.IsAuthenticated(); ok {
|
|
return nil
|
|
}
|
|
|
|
// create command
|
|
cmd := exec.CommandContext(ctx,
|
|
"op", "signin",
|
|
"--account", op.cfg.Account,
|
|
"--raw",
|
|
)
|
|
|
|
var stdoutBuf bytes.Buffer
|
|
cmd.Stdout = &stdoutBuf
|
|
cmd.Stdin = os.Stdin
|
|
|
|
// start the process and wait till it's finished
|
|
if err := cmd.Start(); err != nil {
|
|
return err
|
|
} else if err := cmd.Wait(); err != nil {
|
|
return err
|
|
}
|
|
|
|
token := strings.TrimSuffix(stdoutBuf.String(), "\n")
|
|
if token == "" {
|
|
return errors.New("failed to retrieve 1password token!")
|
|
} else if err := os.Setenv(fmt.Sprintf("OP_SESSION_%s", op.cfg.Account), token); err != nil {
|
|
return err
|
|
} else {
|
|
op.l.Infof(`If you need op outside the shell, run:
|
|
|
|
$ export OP_SESSION_%s=%s
|
|
|
|
`, op.cfg.Account, token)
|
|
}
|
|
|
|
if op.cfg.TokenFilename != "" {
|
|
if err := os.MkdirAll(path.Dir(op.cfg.TokenFilename), os.ModePerm); err != nil {
|
|
return err
|
|
} else if err := os.WriteFile(op.cfg.TokenFilename, []byte(fmt.Sprintf("OP_SESSION_%s=%s\n", op.cfg.Account, token)), 0600); err != nil {
|
|
return err
|
|
} else {
|
|
op.l.Infof(`Session env has been stored for your convenience at:
|
|
|
|
%s
|
|
|
|
`, op.cfg.TokenFilename)
|
|
}
|
|
}
|
|
op.watch()
|
|
return nil
|
|
}
|
|
|
|
func (op *OnePassword) Get(ctx context.Context, secret Secret) (string, error) {
|
|
if op.connect != nil {
|
|
if fields := op.connectGet(secret.Vault, secret.Item); len(fields) == 0 {
|
|
return "", fmt.Errorf("could not find secret '%s' '%s'", secret.Vault, secret.Item)
|
|
} else if value, ok := fields[secret.Field]; !ok {
|
|
return "", fmt.Errorf("could not find field %s", secret.Field)
|
|
} else {
|
|
return strings.ReplaceAll(strings.TrimSpace(value), "\\n", "\n"), nil
|
|
}
|
|
} else {
|
|
if ok, _ := op.IsAuthenticated(); !ok {
|
|
return "", ErrNotSignedIn
|
|
} else if fields := op.clientGet(ctx, secret.Vault, secret.Item); len(fields) == 0 {
|
|
return "", fmt.Errorf("could not find secret '%s' '%s'", secret.Vault, secret.Item)
|
|
} else if value, ok := fields[secret.Field]; !ok {
|
|
return "", fmt.Errorf("could not find field %s", secret.Field)
|
|
} else {
|
|
return strings.ReplaceAll(strings.TrimSpace(value), "\\n", "\n"), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func (op *OnePassword) GetDocument(ctx context.Context, secret Secret) (string, error) {
|
|
if op.connect != nil {
|
|
if value := op.connectGetFileContent(secret.Field, secret.Vault, secret.Item); len(value) == 0 {
|
|
return "", fmt.Errorf("could not find document: '%s' '%s' '%s'", secret.Field, secret.Vault, secret.Item)
|
|
} else {
|
|
return value, nil
|
|
}
|
|
} else {
|
|
if ok, _ := op.IsAuthenticated(); !ok {
|
|
return "", ErrNotSignedIn
|
|
} else if value := op.clientGetDoument(ctx, secret.Vault, secret.Item); len(value) == 0 {
|
|
return "", fmt.Errorf("could not find document '%s' '%s'", secret.Vault, secret.Item)
|
|
} else {
|
|
return value, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func (op *OnePassword) GetOnetimePassword(ctx context.Context, account, uuid string) (string, error) {
|
|
if ok, _ := op.IsAuthenticated(); !ok {
|
|
return "", ErrNotSignedIn
|
|
}
|
|
|
|
out, err := exec.CommandContext(ctx,
|
|
"op", "item", "get", "--otp", uuid,
|
|
).Output()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return strings.ReplaceAll(strings.TrimSpace(string(out)), "\\n", "\n"), nil
|
|
}
|
|
|
|
func (op *OnePassword) Render(ctx context.Context, source string) ([]byte, error) {
|
|
tpl, err := template.New("1password").
|
|
Delims("<% ", " %>").
|
|
Option("missingkey=error").
|
|
Funcs(
|
|
template.FuncMap{
|
|
"env": func(name string) (string, error) {
|
|
value := os.Getenv(name)
|
|
if value == "" {
|
|
return "", fmt.Errorf("env variable %q was empty", name)
|
|
}
|
|
return value, nil
|
|
},
|
|
"op": func(account, vaultID, itemID, field string) (string, error) {
|
|
return op.Get(ctx, Secret{
|
|
Field: field,
|
|
Item: itemID,
|
|
Vault: vaultID,
|
|
Account: account,
|
|
})
|
|
},
|
|
"indent": func(spaces int, v string) string {
|
|
pad := strings.Repeat(" ", spaces)
|
|
return strings.ReplaceAll(v, "\n", "\n"+pad)
|
|
},
|
|
"quote": func(v string) string {
|
|
return "'" + v + "'"
|
|
},
|
|
"replace": func(o, n, v string) string {
|
|
return strings.ReplaceAll(v, o, n)
|
|
},
|
|
"base64": func(v string) string {
|
|
return base64.StdEncoding.EncodeToString([]byte(v))
|
|
},
|
|
},
|
|
).
|
|
Parse(source)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := bytes.NewBuffer([]byte{})
|
|
if err := tpl.Execute(out, nil); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return out.Bytes(), nil
|
|
}
|
|
|
|
func (op *OnePassword) RenderFile(ctx context.Context, source string) ([]byte, error) {
|
|
in, err := os.ReadFile(source)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out, err := op.Render(ctx, string(in))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (op *OnePassword) RenderFileTo(ctx context.Context, source, target string) error {
|
|
out, err := op.RenderFile(ctx, source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
value := fmt.Sprintf(
|
|
"# Code generated by shell %s - DO NOT EDIT.\n%s",
|
|
time.Now().Format("2006-01-02 15:04:05"),
|
|
string(out),
|
|
)
|
|
return os.WriteFile(target, []byte(value), 0600)
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------------------------
|
|
// ~ Private methods
|
|
// ------------------------------------------------------------------------------------------------
|
|
|
|
//nolint:forcetypeassert
|
|
func (op *OnePassword) clientGet(ctx context.Context, vaultUUID string, itemUUID string) map[string]string {
|
|
return op.cache.Get(fmt.Sprintf("item:%s@%s", itemUUID, vaultUUID), func() any {
|
|
ret := map[string]string{}
|
|
var v struct {
|
|
Vault struct {
|
|
ID string `json:"id"`
|
|
} `json:"vault"`
|
|
Fields []struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"` // CONCEALED, STRING
|
|
Label string `json:"label"`
|
|
Value interface{} `json:"value"`
|
|
} `json:"fields"`
|
|
}
|
|
if res, err := exec.CommandContext(ctx,
|
|
"op", "item", "get", itemUUID,
|
|
"--vault", vaultUUID,
|
|
"--format", "json",
|
|
).CombinedOutput(); err != nil {
|
|
op.l.Error("failed to retrieve item", err.Error())
|
|
return ret
|
|
} else if err := json.Unmarshal(res, &v); err != nil {
|
|
op.l.Error("failed to retrieve item", err.Error())
|
|
return ret
|
|
} else if v.Vault.ID != vaultUUID {
|
|
op.l.Errorf("failed to retrieve item: wrong vault UUID %s for item %s", vaultUUID, itemUUID)
|
|
return ret
|
|
} else {
|
|
ret := map[string]string{}
|
|
aliases := map[string]string{
|
|
"notesPlain": "notes",
|
|
}
|
|
for _, field := range v.Fields {
|
|
if alias, ok := aliases[field.Label]; ok {
|
|
ret[alias] = fmt.Sprintf("%v", field.Value)
|
|
} else {
|
|
ret[field.Label] = fmt.Sprintf("%v", field.Value)
|
|
}
|
|
}
|
|
return ret
|
|
}
|
|
}).(map[string]string)
|
|
}
|
|
|
|
//nolint:forcetypeassert
|
|
func (op *OnePassword) clientGetDoument(ctx context.Context, vaultQuery, itemQuery string) string {
|
|
return op.cache.Get(fmt.Sprintf("document:%s@%s", itemQuery, vaultQuery), func() any {
|
|
var ret string
|
|
if res, err := exec.CommandContext(ctx,
|
|
"op", "document", "get", itemQuery,
|
|
"--vault", vaultQuery,
|
|
).CombinedOutput(); err != nil {
|
|
op.l.Error("failed to retrieve document", err.Error())
|
|
return ""
|
|
} else {
|
|
ret = string(res)
|
|
}
|
|
return ret
|
|
}).(string)
|
|
}
|
|
|
|
//nolint:forcetypeassert
|
|
func (op *OnePassword) connectGet(vaultUUID, itemUUID string) map[string]string {
|
|
return op.cache.Get(strings.Join([]string{vaultUUID, itemUUID}, "#"), func() any {
|
|
ret := map[string]string{}
|
|
var item *onepassword.Item
|
|
if op.uuidRegex.Match([]byte(itemUUID)) {
|
|
if v, err := op.connect.GetItem(itemUUID, vaultUUID); err != nil {
|
|
op.l.Error("failed to retrieve item:", err.Error())
|
|
return ret
|
|
} else {
|
|
item = v
|
|
}
|
|
} else {
|
|
if v, err := op.connect.GetItemByTitle(itemUUID, vaultUUID); err != nil {
|
|
op.l.Error("failed to retrieve item by title:", err.Error())
|
|
return ret
|
|
} else {
|
|
item = v
|
|
}
|
|
}
|
|
for _, f := range item.Fields {
|
|
ret[f.Label] = f.Value
|
|
}
|
|
return ret
|
|
}).(map[string]string)
|
|
}
|
|
|
|
//nolint:forcetypeassert
|
|
func (op *OnePassword) connectGetFileContent(vaultQuery, itemQuery, fileUUID string) string {
|
|
return op.cache.Get(strings.Join([]string{vaultQuery, itemQuery}, "#"), func() any {
|
|
var ret string
|
|
if v, err := op.connect.GetFile(fileUUID, itemQuery, vaultQuery); err != nil {
|
|
op.l.Error("failed to retrieve file:", err.Error())
|
|
return ret
|
|
} else if c, err := op.connect.GetFileContent(v); err != nil {
|
|
op.l.Error("failed to retrieve file content:", err.Error())
|
|
} else {
|
|
ret = string(c)
|
|
}
|
|
return ret
|
|
}).(string)
|
|
}
|
|
|
|
func (op *OnePassword) watch() {
|
|
if v, ok := op.watching[op.cfg.Account]; !ok || !v {
|
|
go func() {
|
|
for {
|
|
if ok, err := op.IsAuthenticated(); err != nil {
|
|
op.l.Warnf("\n1password session keep alive failed for '%s' (%s)", op.cfg.Account, err.Error())
|
|
op.watching[op.cfg.Account] = false
|
|
return
|
|
} else if !ok {
|
|
op.l.Warnf("\n1password session keep alive failed for '%s'", op.cfg.Account)
|
|
op.watching[op.cfg.Account] = false
|
|
return
|
|
}
|
|
time.Sleep(time.Minute * 15)
|
|
}
|
|
}()
|
|
op.watching[op.cfg.Account] = true
|
|
}
|
|
}
|