bridge: huge refactor to accept multiple kind of credentials

Michael Muré created

Change summary

bridge/bridges.go                   |   4 
bridge/core/auth/credential.go      | 232 ++++++++++++++++++++++++
bridge/core/auth/credential_test.go | 109 +++++++++++
bridge/core/auth/options.go         |  62 ++++++
bridge/core/auth/token.go           |  95 +++++++++
bridge/core/bridge.go               |  32 +--
bridge/core/interfaces.go           |   7 
bridge/core/token.go                | 296 -------------------------------
bridge/github/config.go             | 135 ++++++-------
bridge/github/config_test.go        |  24 +
bridge/github/export.go             | 108 ++++++----
bridge/github/export_test.go        |  43 ++--
bridge/github/github.go             |   5 
bridge/github/import.go             |  38 +++
bridge/github/import_test.go        |  15 +
bridge/github/iterator.go           |   4 
bridge/gitlab/config.go             | 138 +++++++-------
bridge/gitlab/export.go             |  72 +++---
bridge/gitlab/export_test.go        |  23 +-
bridge/gitlab/gitlab.go             |   6 
bridge/gitlab/import.go             |  36 +++
bridge/gitlab/import_test.go        |  15 +
bridge/gitlab/iterator.go           |   4 
bridge/launchpad/config.go          |  39 +--
bridge/launchpad/import.go          |   4 
commands/add.go                     |   2 
commands/bridge_auth.go             |  52 +++-
commands/bridge_auth_addtoken.go    |  15 +
commands/bridge_auth_rm.go          |   8 
commands/bridge_auth_show.go        |  18 +
commands/bridge_configure.go        |  50 +++-
commands/bridge_pull.go             |   2 
commands/bridge_push.go             |   2 
commands/comment_add.go             |   2 
commands/label_add.go               |   2 
commands/root.go                    |   8 
commands/status_close.go            |   2 
commands/status_open.go             |   2 
commands/title_edit.go              |   2 
commands/user.go                    |   5 
commands/user_create.go             |   4 
commands/webui.go                   |   2 
doc/man/git-bug-bridge-configure.1  |   8 
doc/md/git-bug_bridge_configure.md  |  18 
entity/err.go                       |   5 
identity/identity.go                |  20 -
misc/bash_completion/git-bug        |   9 
misc/powershell_completion/git-bug  |   7 
misc/zsh_completion/git-bug         |   4 
repository/config_mem.go            |  26 +-
repository/mock_repo.go             |  23 +-
repository/repo.go                  |  16 +
52 files changed, 1,091 insertions(+), 769 deletions(-)

Detailed changes

bridge/bridges.go 🔗

@@ -39,11 +39,11 @@ func DefaultBridge(repo *cache.RepoCache) (*core.Bridge, error) {
 
 // ConfiguredBridges return the list of bridge that are configured for the given
 // repo
-func ConfiguredBridges(repo repository.RepoCommon) ([]string, error) {
+func ConfiguredBridges(repo repository.RepoConfig) ([]string, error) {
 	return core.ConfiguredBridges(repo)
 }
 
 // Remove a configured bridge
-func RemoveBridge(repo repository.RepoCommon, name string) error {
+func RemoveBridge(repo repository.RepoConfig, name string) error {
 	return core.RemoveBridge(repo, name)
 }

bridge/core/auth/credential.go 🔗

@@ -0,0 +1,232 @@
+package auth
+
+import (
+	"errors"
+	"fmt"
+	"regexp"
+	"strings"
+	"time"
+
+	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/repository"
+)
+
+const (
+	configKeyPrefix     = "git-bug.auth"
+	configKeyKind       = "kind"
+	configKeyUserId     = "userid"
+	configKeyTarget     = "target"
+	configKeyCreateTime = "createtime"
+)
+
+type CredentialKind string
+
+const (
+	KindToken         = "token"
+	KindLoginPassword = "login-password"
+)
+
+var ErrCredentialNotExist = errors.New("credential doesn't exist")
+
+func NewErrMultipleMatchCredential(matching []entity.Id) *entity.ErrMultipleMatch {
+	return entity.NewErrMultipleMatch("credential", matching)
+}
+
+type Credential interface {
+	ID() entity.Id
+	UserId() entity.Id
+	Target() string
+	Kind() CredentialKind
+	CreateTime() time.Time
+	Validate() error
+
+	// Return all the specific properties of the credential that need to be saved into the configuration.
+	// This does not include Target, User, Kind and CreateTime.
+	ToConfig() map[string]string
+}
+
+// Load loads a credential from the repo config
+func LoadWithId(repo repository.RepoConfig, id entity.Id) (Credential, error) {
+	keyPrefix := fmt.Sprintf("%s.%s.", configKeyPrefix, id)
+
+	// read token config pairs
+	rawconfigs, err := repo.GlobalConfig().ReadAll(keyPrefix)
+	if err != nil {
+		// Not exactly right due to the limitation of ReadAll()
+		return nil, ErrCredentialNotExist
+	}
+
+	return loadFromConfig(rawconfigs, id)
+}
+
+// LoadWithPrefix load a credential from the repo config with a prefix
+func LoadWithPrefix(repo repository.RepoConfig, prefix string) (Credential, error) {
+	creds, err := List(repo)
+	if err != nil {
+		return nil, err
+	}
+
+	// preallocate but empty
+	matching := make([]Credential, 0, 5)
+
+	for _, cred := range creds {
+		if cred.ID().HasPrefix(prefix) {
+			matching = append(matching, cred)
+		}
+	}
+
+	if len(matching) > 1 {
+		ids := make([]entity.Id, len(matching))
+		for i, cred := range matching {
+			ids[i] = cred.ID()
+		}
+		return nil, NewErrMultipleMatchCredential(ids)
+	}
+
+	if len(matching) == 0 {
+		return nil, ErrCredentialNotExist
+	}
+
+	return matching[0], nil
+}
+
+func loadFromConfig(rawConfigs map[string]string, id entity.Id) (Credential, error) {
+	keyPrefix := fmt.Sprintf("%s.%s.", configKeyPrefix, id)
+
+	// trim key prefix
+	configs := make(map[string]string)
+	for key, value := range rawConfigs {
+		newKey := strings.TrimPrefix(key, keyPrefix)
+		configs[newKey] = value
+	}
+
+	var cred Credential
+
+	switch configs[configKeyKind] {
+	case KindToken:
+		cred = NewTokenFromConfig(configs)
+	case KindLoginPassword:
+	default:
+		return nil, fmt.Errorf("unknown credential type %s", configs[configKeyKind])
+	}
+
+	return cred, nil
+}
+
+// List load all existing credentials
+func List(repo repository.RepoConfig, opts ...Option) ([]Credential, error) {
+	rawConfigs, err := repo.GlobalConfig().ReadAll(configKeyPrefix + ".")
+	if err != nil {
+		return nil, err
+	}
+
+	re, err := regexp.Compile(configKeyPrefix + `.([^.]+).([^.]+)`)
+	if err != nil {
+		panic(err)
+	}
+
+	mapped := make(map[string]map[string]string)
+
+	for key, val := range rawConfigs {
+		res := re.FindStringSubmatch(key)
+		if res == nil {
+			continue
+		}
+		if mapped[res[1]] == nil {
+			mapped[res[1]] = make(map[string]string)
+		}
+		mapped[res[1]][res[2]] = val
+	}
+
+	matcher := matcher(opts)
+
+	var credentials []Credential
+	for id, kvs := range mapped {
+		cred, err := loadFromConfig(kvs, entity.Id(id))
+		if err != nil {
+			return nil, err
+		}
+		if matcher.Match(cred) {
+			credentials = append(credentials, cred)
+		}
+	}
+
+	return credentials, nil
+}
+
+// IdExist return whether a credential id exist or not
+func IdExist(repo repository.RepoConfig, id entity.Id) bool {
+	_, err := LoadWithId(repo, id)
+	return err == nil
+}
+
+// PrefixExist return whether a credential id prefix exist or not
+func PrefixExist(repo repository.RepoConfig, prefix string) bool {
+	_, err := LoadWithPrefix(repo, prefix)
+	return err == nil
+}
+
+// Store stores a credential in the global git config
+func Store(repo repository.RepoConfig, cred Credential) error {
+	confs := cred.ToConfig()
+
+	prefix := fmt.Sprintf("%s.%s.", configKeyPrefix, cred.ID())
+
+	// Kind
+	err := repo.GlobalConfig().StoreString(prefix+configKeyKind, string(cred.Kind()))
+	if err != nil {
+		return err
+	}
+
+	// UserId
+	err = repo.GlobalConfig().StoreString(prefix+configKeyUserId, cred.UserId().String())
+	if err != nil {
+		return err
+	}
+
+	// Target
+	err = repo.GlobalConfig().StoreString(prefix+configKeyTarget, cred.Target())
+	if err != nil {
+		return err
+	}
+
+	// CreateTime
+	err = repo.GlobalConfig().StoreTimestamp(prefix+configKeyCreateTime, cred.CreateTime())
+	if err != nil {
+		return err
+	}
+
+	// Custom
+	for key, val := range confs {
+		err := repo.GlobalConfig().StoreString(prefix+key, val)
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// Remove removes a credential from the global git config
+func Remove(repo repository.RepoConfig, id entity.Id) error {
+	keyPrefix := fmt.Sprintf("%s.%s", configKeyPrefix, id)
+	return repo.GlobalConfig().RemoveAll(keyPrefix)
+}
+
+/*
+ * Sorting
+ */
+
+type ById []Credential
+
+func (b ById) Len() int {
+	return len(b)
+}
+
+func (b ById) Less(i, j int) bool {
+	return b[i].ID() < b[j].ID()
+}
+
+func (b ById) Swap(i, j int) {
+	b[i], b[j] = b[j], b[i]
+}

bridge/core/auth/credential_test.go 🔗

@@ -0,0 +1,109 @@
+package auth
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/identity"
+	"github.com/MichaelMure/git-bug/repository"
+)
+
+func TestCredential(t *testing.T) {
+	repo := repository.NewMockRepoForTest()
+
+	user1 := identity.NewIdentity("user1", "email")
+	err := user1.Commit(repo)
+	assert.NoError(t, err)
+
+	user2 := identity.NewIdentity("user2", "email")
+	err = user2.Commit(repo)
+	assert.NoError(t, err)
+
+	storeToken := func(user identity.Interface, val string, target string) *Token {
+		token := NewToken(user.Id(), val, target)
+		err = Store(repo, token)
+		require.NoError(t, err)
+		return token
+	}
+
+	token := storeToken(user1, "foobar", "github")
+
+	// Store + Load
+	err = Store(repo, token)
+	assert.NoError(t, err)
+
+	token2, err := LoadWithId(repo, token.ID())
+	assert.NoError(t, err)
+	assert.Equal(t, token.createTime.Unix(), token2.CreateTime().Unix())
+	token.createTime = token2.CreateTime()
+	assert.Equal(t, token, token2)
+
+	prefix := string(token.ID())[:10]
+
+	// LoadWithPrefix
+	token3, err := LoadWithPrefix(repo, prefix)
+	assert.NoError(t, err)
+	assert.Equal(t, token.createTime.Unix(), token3.CreateTime().Unix())
+	token.createTime = token3.CreateTime()
+	assert.Equal(t, token, token3)
+
+	token4 := storeToken(user1, "foo", "gitlab")
+	token5 := storeToken(user2, "bar", "github")
+
+	// List + options
+	creds, err := List(repo, WithTarget("github"))
+	assert.NoError(t, err)
+	sameIds(t, creds, []Credential{token, token5})
+
+	creds, err = List(repo, WithTarget("gitlab"))
+	assert.NoError(t, err)
+	sameIds(t, creds, []Credential{token4})
+
+	creds, err = List(repo, WithUser(user1))
+	assert.NoError(t, err)
+	sameIds(t, creds, []Credential{token, token4})
+
+	creds, err = List(repo, WithUserId(user1.Id()))
+	assert.NoError(t, err)
+	sameIds(t, creds, []Credential{token, token4})
+
+	creds, err = List(repo, WithKind(KindToken))
+	assert.NoError(t, err)
+	sameIds(t, creds, []Credential{token, token4, token5})
+
+	creds, err = List(repo, WithKind(KindLoginPassword))
+	assert.NoError(t, err)
+	sameIds(t, creds, []Credential{})
+
+	// Exist
+	exist := IdExist(repo, token.ID())
+	assert.True(t, exist)
+
+	exist = PrefixExist(repo, prefix)
+	assert.True(t, exist)
+
+	// Remove
+	err = Remove(repo, token.ID())
+	assert.NoError(t, err)
+
+	creds, err = List(repo)
+	assert.NoError(t, err)
+	sameIds(t, creds, []Credential{token4, token5})
+}
+
+func sameIds(t *testing.T, a []Credential, b []Credential) {
+	t.Helper()
+
+	ids := func(creds []Credential) []entity.Id {
+		result := make([]entity.Id, len(creds))
+		for i, cred := range creds {
+			result[i] = cred.ID()
+		}
+		return result
+	}
+
+	assert.ElementsMatch(t, ids(a), ids(b))
+}

bridge/core/auth/options.go 🔗

@@ -0,0 +1,62 @@
+package auth
+
+import (
+	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/identity"
+)
+
+type options struct {
+	target string
+	userId entity.Id
+	kind   CredentialKind
+}
+
+type Option func(opts *options)
+
+func matcher(opts []Option) *options {
+	result := &options{}
+	for _, opt := range opts {
+		opt(result)
+	}
+	return result
+}
+
+func (opts *options) Match(cred Credential) bool {
+	if opts.target != "" && cred.Target() != opts.target {
+		return false
+	}
+
+	if opts.userId != "" && cred.UserId() != opts.userId {
+		return false
+	}
+
+	if opts.kind != "" && cred.Kind() != opts.kind {
+		return false
+	}
+
+	return true
+}
+
+func WithTarget(target string) Option {
+	return func(opts *options) {
+		opts.target = target
+	}
+}
+
+func WithUser(user identity.Interface) Option {
+	return func(opts *options) {
+		opts.userId = user.Id()
+	}
+}
+
+func WithUserId(userId entity.Id) Option {
+	return func(opts *options) {
+		opts.userId = userId
+	}
+}
+
+func WithKind(kind CredentialKind) Option {
+	return func(opts *options) {
+		opts.kind = kind
+	}
+}

bridge/core/auth/token.go 🔗

@@ -0,0 +1,95 @@
+package auth
+
+import (
+	"crypto/sha256"
+	"fmt"
+	"time"
+
+	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/repository"
+)
+
+const (
+	tokenValueKey = "value"
+)
+
+var _ Credential = &Token{}
+
+// Token holds an API access token data
+type Token struct {
+	userId     entity.Id
+	target     string
+	createTime time.Time
+	Value      string
+}
+
+// NewToken instantiate a new token
+func NewToken(userId entity.Id, value, target string) *Token {
+	return &Token{
+		userId:     userId,
+		target:     target,
+		createTime: time.Now(),
+		Value:      value,
+	}
+}
+
+func NewTokenFromConfig(conf map[string]string) *Token {
+	token := &Token{}
+
+	token.userId = entity.Id(conf[configKeyUserId])
+	token.target = conf[configKeyTarget]
+	if createTime, ok := conf[configKeyCreateTime]; ok {
+		if t, err := repository.ParseTimestamp(createTime); err == nil {
+			token.createTime = t
+		}
+	}
+
+	token.Value = conf[tokenValueKey]
+
+	return token
+}
+
+func (t *Token) ID() entity.Id {
+	sum := sha256.Sum256([]byte(t.target + t.Value))
+	return entity.Id(fmt.Sprintf("%x", sum))
+}
+
+func (t *Token) UserId() entity.Id {
+	return t.userId
+}
+
+func (t *Token) Target() string {
+	return t.target
+}
+
+func (t *Token) Kind() CredentialKind {
+	return KindToken
+}
+
+func (t *Token) CreateTime() time.Time {
+	return t.createTime
+}
+
+// Validate ensure token important fields are valid
+func (t *Token) Validate() error {
+	if t.Value == "" {
+		return fmt.Errorf("missing value")
+	}
+	if t.target == "" {
+		return fmt.Errorf("missing target")
+	}
+	if t.createTime.IsZero() || t.createTime.Equal(time.Time{}) {
+		return fmt.Errorf("missing creation time")
+	}
+	if !core.TargetExist(t.target) {
+		return fmt.Errorf("unknown target")
+	}
+	return nil
+}
+
+func (t *Token) ToConfig() map[string]string {
+	return map[string]string{
+		tokenValueKey: t.Value,
+	}
+}

bridge/core/bridge.go 🔗

@@ -13,7 +13,6 @@ import (
 	"github.com/pkg/errors"
 
 	"github.com/MichaelMure/git-bug/cache"
-	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/repository"
 )
 
@@ -21,10 +20,9 @@ var ErrImportNotSupported = errors.New("import is not supported")
 var ErrExportNotSupported = errors.New("export is not supported")
 
 const (
-	ConfigKeyTarget  = "target"
-	ConfigKeyToken   = "token"
-	ConfigKeyTokenId = "token-id"
-	MetaKeyOrigin    = "origin"
+	ConfigKeyTarget = "target"
+
+	MetaKeyOrigin = "origin"
 
 	bridgeConfigKeyPrefix = "git-bug.bridge"
 )
@@ -37,9 +35,8 @@ type BridgeParams struct {
 	Owner      string
 	Project    string
 	URL        string
-	Token      string
-	TokenId    string
-	TokenStdin bool
+	CredPrefix string
+	TokenRaw   string
 }
 
 // Bridge is a wrapper around a BridgeImpl that will bind low-level
@@ -143,7 +140,7 @@ func DefaultBridge(repo *cache.RepoCache) (*Bridge, error) {
 
 // ConfiguredBridges return the list of bridge that are configured for the given
 // repo
-func ConfiguredBridges(repo repository.RepoCommon) ([]string, error) {
+func ConfiguredBridges(repo repository.RepoConfig) ([]string, error) {
 	configs, err := repo.LocalConfig().ReadAll(bridgeConfigKeyPrefix + ".")
 	if err != nil {
 		return nil, errors.Wrap(err, "can't read configured bridges")
@@ -178,7 +175,7 @@ func ConfiguredBridges(repo repository.RepoCommon) ([]string, error) {
 }
 
 // Check if a bridge exist
-func BridgeExist(repo repository.RepoCommon, name string) bool {
+func BridgeExist(repo repository.RepoConfig, name string) bool {
 	keyPrefix := fmt.Sprintf("git-bug.bridge.%s.", name)
 
 	conf, err := repo.LocalConfig().ReadAll(keyPrefix)
@@ -187,7 +184,7 @@ func BridgeExist(repo repository.RepoCommon, name string) bool {
 }
 
 // Remove a configured bridge
-func RemoveBridge(repo repository.RepoCommon, name string) error {
+func RemoveBridge(repo repository.RepoConfig, name string) error {
 	re, err := regexp.Compile(`^[a-zA-Z0-9]+`)
 	if err != nil {
 		panic(err)
@@ -242,7 +239,7 @@ func (b *Bridge) ensureConfig() error {
 	return nil
 }
 
-func loadConfig(repo repository.RepoCommon, name string) (Configuration, error) {
+func loadConfig(repo repository.RepoConfig, name string) (Configuration, error) {
 	keyPrefix := fmt.Sprintf("git-bug.bridge.%s.", name)
 
 	pairs, err := repo.LocalConfig().ReadAll(keyPrefix)
@@ -280,16 +277,9 @@ func (b *Bridge) ensureInit() error {
 		return nil
 	}
 
-	token, err := LoadToken(b.repo, entity.Id(b.conf[ConfigKeyTokenId]))
-	if err != nil {
-		return err
-	}
-
-	b.conf[ConfigKeyToken] = token.Value
-
 	importer := b.getImporter()
 	if importer != nil {
-		err := importer.Init(b.conf)
+		err := importer.Init(b.repo, b.conf)
 		if err != nil {
 			return err
 		}
@@ -297,7 +287,7 @@ func (b *Bridge) ensureInit() error {
 
 	exporter := b.getExporter()
 	if exporter != nil {
-		err := exporter.Init(b.conf)
+		err := exporter.Init(b.repo, b.conf)
 		if err != nil {
 			return err
 		}

bridge/core/interfaces.go 🔗

@@ -5,7 +5,6 @@ import (
 	"time"
 
 	"github.com/MichaelMure/git-bug/cache"
-	"github.com/MichaelMure/git-bug/repository"
 )
 
 type Configuration map[string]string
@@ -16,7 +15,7 @@ type BridgeImpl interface {
 
 	// Configure handle the user interaction and return a key/value configuration
 	// for future use
-	Configure(repo repository.RepoCommon, params BridgeParams) (Configuration, error)
+	Configure(repo *cache.RepoCache, params BridgeParams) (Configuration, error)
 
 	// ValidateConfig check the configuration for error
 	ValidateConfig(conf Configuration) error
@@ -29,11 +28,11 @@ type BridgeImpl interface {
 }
 
 type Importer interface {
-	Init(conf Configuration) error
+	Init(repo *cache.RepoCache, conf Configuration) error
 	ImportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan ImportResult, error)
 }
 
 type Exporter interface {
-	Init(conf Configuration) error
+	Init(repo *cache.RepoCache, conf Configuration) error
 	ExportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan ExportResult, error)
 }

bridge/core/token.go 🔗

@@ -1,296 +0,0 @@
-package core
-
-import (
-	"crypto/sha256"
-	"errors"
-	"fmt"
-	"regexp"
-	"sort"
-	"strings"
-	"time"
-
-	"github.com/MichaelMure/git-bug/entity"
-	"github.com/MichaelMure/git-bug/repository"
-)
-
-const (
-	tokenConfigKeyPrefix = "git-bug.token"
-	tokenValueKey        = "value"
-	tokenTargetKey       = "target"
-	tokenCreateTimeKey   = "createtime"
-)
-
-var ErrTokenNotExist = errors.New("token doesn't exist")
-
-func NewErrMultipleMatchToken(matching []entity.Id) *entity.ErrMultipleMatch {
-	return entity.NewErrMultipleMatch("token", matching)
-}
-
-// Token holds an API access token data
-type Token struct {
-	Value      string
-	Target     string
-	CreateTime time.Time
-}
-
-// NewToken instantiate a new token
-func NewToken(value, target string) *Token {
-	return &Token{
-		Value:      value,
-		Target:     target,
-		CreateTime: time.Now(),
-	}
-}
-
-func (t *Token) ID() entity.Id {
-	sum := sha256.Sum256([]byte(t.Target + t.Value))
-	return entity.Id(fmt.Sprintf("%x", sum))
-}
-
-// Validate ensure token important fields are valid
-func (t *Token) Validate() error {
-	if t.Value == "" {
-		return fmt.Errorf("missing value")
-	}
-	if t.Target == "" {
-		return fmt.Errorf("missing target")
-	}
-	if t.CreateTime.IsZero() || t.CreateTime.Equal(time.Time{}) {
-		return fmt.Errorf("missing creation time")
-	}
-	if !TargetExist(t.Target) {
-		return fmt.Errorf("unknown target")
-	}
-	return nil
-}
-
-// LoadToken loads a token from the repo config
-func LoadToken(repo repository.RepoCommon, id entity.Id) (*Token, error) {
-	keyPrefix := fmt.Sprintf("git-bug.token.%s.", id)
-
-	// read token config pairs
-	rawconfigs, err := repo.GlobalConfig().ReadAll(keyPrefix)
-	if err != nil {
-		// Not exactly right due to the limitation of ReadAll()
-		return nil, ErrTokenNotExist
-	}
-
-	// trim key prefix
-	configs := make(map[string]string)
-	for key, value := range rawconfigs {
-		newKey := strings.TrimPrefix(key, keyPrefix)
-		configs[newKey] = value
-	}
-
-	token := &Token{}
-
-	token.Value = configs[tokenValueKey]
-	token.Target = configs[tokenTargetKey]
-	if createTime, ok := configs[tokenCreateTimeKey]; ok {
-		if t, err := repository.ParseTimestamp(createTime); err == nil {
-			token.CreateTime = t
-		}
-	}
-
-	return token, nil
-}
-
-// LoadTokenPrefix load a token from the repo config with a prefix
-func LoadTokenPrefix(repo repository.RepoCommon, prefix string) (*Token, error) {
-	tokens, err := ListTokens(repo)
-	if err != nil {
-		return nil, err
-	}
-
-	// preallocate but empty
-	matching := make([]entity.Id, 0, 5)
-
-	for _, id := range tokens {
-		if id.HasPrefix(prefix) {
-			matching = append(matching, id)
-		}
-	}
-
-	if len(matching) > 1 {
-		return nil, NewErrMultipleMatchToken(matching)
-	}
-
-	if len(matching) == 0 {
-		return nil, ErrTokenNotExist
-	}
-
-	return LoadToken(repo, matching[0])
-}
-
-// ListTokens list all existing token ids
-func ListTokens(repo repository.RepoCommon) ([]entity.Id, error) {
-	configs, err := repo.GlobalConfig().ReadAll(tokenConfigKeyPrefix + ".")
-	if err != nil {
-		return nil, err
-	}
-
-	re, err := regexp.Compile(tokenConfigKeyPrefix + `.([^.]+)`)
-	if err != nil {
-		panic(err)
-	}
-
-	set := make(map[string]interface{})
-
-	for key := range configs {
-		res := re.FindStringSubmatch(key)
-
-		if res == nil {
-			continue
-		}
-
-		set[res[1]] = nil
-	}
-
-	result := make([]entity.Id, 0, len(set))
-	for key := range set {
-		result = append(result, entity.Id(key))
-	}
-
-	sort.Sort(entity.Alphabetical(result))
-
-	return result, nil
-}
-
-// ListTokensWithTarget list all token ids associated with the target
-func ListTokensWithTarget(repo repository.RepoCommon, target string) ([]entity.Id, error) {
-	var ids []entity.Id
-	tokensIds, err := ListTokens(repo)
-	if err != nil {
-		return nil, err
-	}
-
-	for _, tokenId := range tokensIds {
-		token, err := LoadToken(repo, tokenId)
-		if err != nil {
-			return nil, err
-		}
-
-		if token.Target == target {
-			ids = append(ids, tokenId)
-		}
-	}
-	return ids, nil
-}
-
-// LoadTokens load all existing tokens
-func LoadTokens(repo repository.RepoCommon) ([]*Token, error) {
-	tokensIds, err := ListTokens(repo)
-	if err != nil {
-		return nil, err
-	}
-
-	var tokens []*Token
-	for _, id := range tokensIds {
-		token, err := LoadToken(repo, id)
-		if err != nil {
-			return nil, err
-		}
-		tokens = append(tokens, token)
-	}
-	return tokens, nil
-}
-
-// LoadTokensWithTarget load all existing tokens for a given target
-func LoadTokensWithTarget(repo repository.RepoCommon, target string) ([]*Token, error) {
-	tokensIds, err := ListTokens(repo)
-	if err != nil {
-		return nil, err
-	}
-
-	var tokens []*Token
-	for _, id := range tokensIds {
-		token, err := LoadToken(repo, id)
-		if err != nil {
-			return nil, err
-		}
-		if token.Target == target {
-			tokens = append(tokens, token)
-		}
-	}
-	return tokens, nil
-}
-
-// TokenIdExist return wether token id exist or not
-func TokenIdExist(repo repository.RepoCommon, id entity.Id) bool {
-	_, err := LoadToken(repo, id)
-	return err == nil
-}
-
-// TokenExist return wether there is a token with a certain value or not
-func TokenExist(repo repository.RepoCommon, value string) bool {
-	tokens, err := LoadTokens(repo)
-	if err != nil {
-		return false
-	}
-	for _, token := range tokens {
-		if token.Value == value {
-			return true
-		}
-	}
-	return false
-}
-
-// TokenExistWithTarget same as TokenExist but restrict search for a given target
-func TokenExistWithTarget(repo repository.RepoCommon, value string, target string) bool {
-	tokens, err := LoadTokensWithTarget(repo, target)
-	if err != nil {
-		return false
-	}
-	for _, token := range tokens {
-		if token.Value == value {
-			return true
-		}
-	}
-	return false
-}
-
-// StoreToken stores a token in the repo config
-func StoreToken(repo repository.RepoCommon, token *Token) error {
-	storeValueKey := fmt.Sprintf("git-bug.token.%s.%s", token.ID().String(), tokenValueKey)
-	err := repo.GlobalConfig().StoreString(storeValueKey, token.Value)
-	if err != nil {
-		return err
-	}
-
-	storeTargetKey := fmt.Sprintf("git-bug.token.%s.%s", token.ID().String(), tokenTargetKey)
-	err = repo.GlobalConfig().StoreString(storeTargetKey, token.Target)
-	if err != nil {
-		return err
-	}
-
-	createTimeKey := fmt.Sprintf("git-bug.token.%s.%s", token.ID().String(), tokenCreateTimeKey)
-	return repo.GlobalConfig().StoreTimestamp(createTimeKey, token.CreateTime)
-}
-
-// RemoveToken removes a token from the repo config
-func RemoveToken(repo repository.RepoCommon, id entity.Id) error {
-	keyPrefix := fmt.Sprintf("git-bug.token.%s", id)
-	return repo.GlobalConfig().RemoveAll(keyPrefix)
-}
-
-// LoadOrCreateToken will try to load a token matching the same value or create it
-func LoadOrCreateToken(repo repository.RepoCommon, target, tokenValue string) (*Token, error) {
-	tokens, err := LoadTokensWithTarget(repo, target)
-	if err != nil {
-		return nil, err
-	}
-
-	for _, token := range tokens {
-		if token.Value == tokenValue {
-			return token, nil
-		}
-	}
-
-	token := NewToken(tokenValue, target)
-	err = StoreToken(repo, token)
-	if err != nil {
-		return nil, err
-	}
-
-	return token, nil
-}

bridge/github/config.go 🔗

@@ -22,6 +22,8 @@ import (
 	"golang.org/x/crypto/ssh/terminal"
 
 	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bridge/core/auth"
+	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/colors"
@@ -33,7 +35,6 @@ const (
 	githubV3Url = "https://api.github.com"
 	keyOwner    = "owner"
 	keyProject  = "project"
-	keyToken    = "token"
 
 	defaultTimeout = 60 * time.Second
 )
@@ -42,40 +43,33 @@ var (
 	ErrBadProjectURL = errors.New("bad project url")
 )
 
-func (g *Github) Configure(repo repository.RepoCommon, params core.BridgeParams) (core.Configuration, error) {
+func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) {
 	conf := make(core.Configuration)
 	var err error
 
-	if (params.Token != "" || params.TokenId != "" || params.TokenStdin) &&
+	if (params.CredPrefix != "" || params.TokenRaw != "") &&
 		(params.URL == "" && (params.Project == "" || params.Owner == "")) {
 		return nil, fmt.Errorf("you must provide a project URL or Owner/Name to configure this bridge with a token")
 	}
 
 	var owner string
 	var project string
+
 	// getting owner and project name
 	switch {
 	case params.Owner != "" && params.Project != "":
 		// first try to use params if both or project and owner are provided
 		owner = params.Owner
 		project = params.Project
-
 	case params.URL != "":
 		// try to parse params URL and extract owner and project
 		owner, project, err = splitURL(params.URL)
 		if err != nil {
 			return nil, err
 		}
-
 	default:
-		// remote suggestions
-		remotes, err := repo.GetRemotes()
-		if err != nil {
-			return nil, err
-		}
-
 		// terminal prompt
-		owner, project, err = promptURL(remotes)
+		owner, project, err = promptURL(repo)
 		if err != nil {
 			return nil, err
 		}
@@ -90,49 +84,38 @@ func (g *Github) Configure(repo repository.RepoCommon, params core.BridgeParams)
 		return nil, fmt.Errorf("invalid parameter owner: %v", owner)
 	}
 
-	var token string
-	var tokenId entity.Id
-	var tokenObj *core.Token
-
-	// try to get token from params if provided, else use terminal prompt
-	// to either enter a token or login and generate a new one, or choose
-	// an existing token
-	if params.Token != "" {
-		token = params.Token
-	} else if params.TokenStdin {
-		reader := bufio.NewReader(os.Stdin)
-		token, err = reader.ReadString('\n')
-		if err != nil {
-			return nil, fmt.Errorf("reading from stdin: %v", err)
-		}
-		token = strings.TrimSpace(token)
-	} else if params.TokenId != "" {
-		tokenId = entity.Id(params.TokenId)
-	} else {
-		tokenObj, err = promptTokenOptions(repo, owner, project)
-		if err != nil {
-			return nil, err
-		}
+	user, err := repo.GetUserIdentity()
+	if err != nil {
+		return nil, err
 	}
 
-	// at this point, we check if the token already exist or we create a new one
-	if token != "" {
-		tokenObj, err = core.LoadOrCreateToken(repo, target, token)
+	var cred auth.Credential
+
+	switch {
+	case params.CredPrefix != "":
+		cred, err = auth.LoadWithPrefix(repo, params.CredPrefix)
 		if err != nil {
 			return nil, err
 		}
-	} else if tokenId != "" {
-		tokenObj, err = core.LoadToken(repo, tokenId)
+		if cred.UserId() != user.Id() {
+			return nil, fmt.Errorf("selected credential don't match the user")
+		}
+	case params.TokenRaw != "":
+		cred = auth.NewToken(user.Id(), params.TokenRaw, target)
+	default:
+		cred, err = promptTokenOptions(repo, user.Id(), owner, project)
 		if err != nil {
 			return nil, err
 		}
-		if tokenObj.Target != target {
-			return nil, fmt.Errorf("token target is incompatible %s", tokenObj.Target)
-		}
+	}
+
+	token, ok := cred.(*auth.Token)
+	if !ok {
+		return nil, fmt.Errorf("the Github bridge only handle token credentials")
 	}
 
 	// verify access to the repository with token
-	ok, err = validateProject(owner, project, tokenObj.Value)
+	ok, err = validateProject(owner, project, token)
 	if err != nil {
 		return nil, err
 	}
@@ -141,7 +124,6 @@ func (g *Github) Configure(repo repository.RepoCommon, params core.BridgeParams)
 	}
 
 	conf[core.ConfigKeyTarget] = target
-	conf[core.ConfigKeyTokenId] = tokenObj.ID().String()
 	conf[keyOwner] = owner
 	conf[keyProject] = project
 
@@ -150,6 +132,14 @@ func (g *Github) Configure(repo repository.RepoCommon, params core.BridgeParams)
 		return nil, err
 	}
 
+	// don't forget to store the now known valid token
+	if !auth.IdExist(repo, cred.ID()) {
+		err = auth.Store(repo, cred)
+		if err != nil {
+			return nil, err
+		}
+	}
+
 	return conf, nil
 }
 
@@ -160,10 +150,6 @@ func (*Github) ValidateConfig(conf core.Configuration) error {
 		return fmt.Errorf("unexpected target name: %v", v)
 	}
 
-	if _, ok := conf[core.ConfigKeyTokenId]; !ok {
-		return fmt.Errorf("missing %s key", core.ConfigKeyTokenId)
-	}
-
 	if _, ok := conf[keyOwner]; !ok {
 		return fmt.Errorf("missing %s key", keyOwner)
 	}
@@ -245,9 +231,9 @@ func randomFingerprint() string {
 	return string(b)
 }
 
-func promptTokenOptions(repo repository.RepoCommon, owner, project string) (*core.Token, error) {
+func promptTokenOptions(repo repository.RepoConfig, userId entity.Id, owner, project string) (auth.Credential, error) {
 	for {
-		tokens, err := core.LoadTokensWithTarget(repo, target)
+		creds, err := auth.List(repo, auth.WithUserId(userId), auth.WithTarget(target))
 		if err != nil {
 			return nil, err
 		}
@@ -256,18 +242,19 @@ func promptTokenOptions(repo repository.RepoCommon, owner, project string) (*cor
 		fmt.Println("[1]: enter my token")
 		fmt.Println("[2]: interactive token creation")
 
-		if len(tokens) > 0 {
+		if len(creds) > 0 {
+			sort.Sort(auth.ById(creds))
+
 			fmt.Println()
 			fmt.Println("Existing tokens for Github:")
-			for i, token := range tokens {
-				if token.Target == target {
-					fmt.Printf("[%d]: %s => %s (%s)\n",
-						i+3,
-						colors.Cyan(token.ID().Human()),
-						text.TruncateMax(token.Value, 10),
-						token.CreateTime.Format(time.RFC822),
-					)
-				}
+			for i, cred := range creds {
+				token := cred.(*auth.Token)
+				fmt.Printf("[%d]: %s => %s (%s)\n",
+					i+3,
+					colors.Cyan(token.ID().Human()),
+					colors.Red(text.TruncateMax(token.Value, 10)),
+					token.CreateTime().Format(time.RFC822),
+				)
 			}
 		}
 
@@ -281,30 +268,28 @@ func promptTokenOptions(repo repository.RepoCommon, owner, project string) (*cor
 		}
 
 		line = strings.TrimSpace(line)
-
 		index, err := strconv.Atoi(line)
-		if err != nil || index < 1 || index > len(tokens)+2 {
+		if err != nil || index < 1 || index > len(creds)+2 {
 			fmt.Println("invalid input")
 			continue
 		}
 
-		var token string
 		switch index {
 		case 1:
-			token, err = promptToken()
+			value, err := promptToken()
 			if err != nil {
 				return nil, err
 			}
+			return auth.NewToken(userId, value, target), nil
 		case 2:
-			token, err = loginAndRequestToken(owner, project)
+			value, err := loginAndRequestToken(owner, project)
 			if err != nil {
 				return nil, err
 			}
+			return auth.NewToken(userId, value, target), nil
 		default:
-			return tokens[index-3], nil
+			return creds[index-3], nil
 		}
-
-		return core.LoadOrCreateToken(repo, target, token)
 	}
 }
 
@@ -435,7 +420,13 @@ func promptUsername() (string, error) {
 	}
 }
 
-func promptURL(remotes map[string]string) (string, string, error) {
+func promptURL(repo repository.RepoCommon) (string, string, error) {
+	// remote suggestions
+	remotes, err := repo.GetRemotes()
+	if err != nil {
+		return "", "", err
+	}
+
 	validRemotes := getValidGithubRemoteURLs(remotes)
 	if len(validRemotes) > 0 {
 		for {
@@ -556,7 +547,7 @@ func validateUsername(username string) (bool, error) {
 	return resp.StatusCode == http.StatusOK, nil
 }
 
-func validateProject(owner, project, token string) (bool, error) {
+func validateProject(owner, project string, token *auth.Token) (bool, error) {
 	url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
 
 	req, err := http.NewRequest("GET", url, nil)
@@ -565,7 +556,7 @@ func validateProject(owner, project, token string) (bool, error) {
 	}
 
 	// need the token for private repositories
-	req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
+	req.Header.Set("Authorization", fmt.Sprintf("token %s", token.Value))
 
 	client := &http.Client{
 		Timeout: defaultTimeout,

bridge/github/config_test.go 🔗

@@ -5,6 +5,9 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+
+	"github.com/MichaelMure/git-bug/bridge/core/auth"
+	"github.com/MichaelMure/git-bug/entity"
 )
 
 func TestSplitURL(t *testing.T) {
@@ -142,20 +145,23 @@ func TestValidateUsername(t *testing.T) {
 }
 
 func TestValidateProject(t *testing.T) {
-	tokenPrivateScope := os.Getenv("GITHUB_TOKEN_PRIVATE")
-	if tokenPrivateScope == "" {
+	envPrivate := os.Getenv("GITHUB_TOKEN_PRIVATE")
+	if envPrivate == "" {
 		t.Skip("Env var GITHUB_TOKEN_PRIVATE missing")
 	}
 
-	tokenPublicScope := os.Getenv("GITHUB_TOKEN_PUBLIC")
-	if tokenPublicScope == "" {
+	envPublic := os.Getenv("GITHUB_TOKEN_PUBLIC")
+	if envPublic == "" {
 		t.Skip("Env var GITHUB_TOKEN_PUBLIC missing")
 	}
 
+	tokenPrivate := auth.NewToken(entity.UnsetId, envPrivate, target)
+	tokenPublic := auth.NewToken(entity.UnsetId, envPublic, target)
+
 	type args struct {
 		owner   string
 		project string
-		token   string
+		token   *auth.Token
 	}
 	tests := []struct {
 		name string
@@ -167,7 +173,7 @@ func TestValidateProject(t *testing.T) {
 			args: args{
 				project: "git-bug",
 				owner:   "MichaelMure",
-				token:   tokenPublicScope,
+				token:   tokenPublic,
 			},
 			want: true,
 		},
@@ -176,7 +182,7 @@ func TestValidateProject(t *testing.T) {
 			args: args{
 				project: "git-bug-test-github-bridge",
 				owner:   "MichaelMure",
-				token:   tokenPrivateScope,
+				token:   tokenPrivate,
 			},
 			want: true,
 		},
@@ -185,7 +191,7 @@ func TestValidateProject(t *testing.T) {
 			args: args{
 				project: "git-bug-test-github-bridge",
 				owner:   "MichaelMure",
-				token:   tokenPublicScope,
+				token:   tokenPublic,
 			},
 			want: false,
 		},
@@ -194,7 +200,7 @@ func TestValidateProject(t *testing.T) {
 			args: args{
 				project: "cant-find-this",
 				owner:   "organisation-not-found",
-				token:   tokenPublicScope,
+				token:   tokenPublic,
 			},
 			want: false,
 		},

bridge/github/export.go 🔗

@@ -15,9 +15,11 @@ import (
 	"golang.org/x/sync/errgroup"
 
 	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bridge/core/auth"
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/repository"
 )
 
 var (
@@ -31,8 +33,12 @@ type githubExporter struct {
 	// cache identities clients
 	identityClient map[entity.Id]*githubv4.Client
 
-	// map identities with their tokens
-	identityToken map[entity.Id]string
+	// the client to use for non user-specific queries
+	// should be the client of the default user
+	defaultClient *githubv4.Client
+
+	// the token of the default user
+	defaultToken *auth.Token
 
 	// github repository ID
 	repositoryID string
@@ -46,68 +52,86 @@ type githubExporter struct {
 }
 
 // Init .
-func (ge *githubExporter) Init(conf core.Configuration) error {
+func (ge *githubExporter) Init(repo *cache.RepoCache, conf core.Configuration) error {
 	ge.conf = conf
-	//TODO: initialize with multiple tokens
-	ge.identityToken = make(map[entity.Id]string)
 	ge.identityClient = make(map[entity.Id]*githubv4.Client)
 	ge.cachedOperationIDs = make(map[entity.Id]string)
 	ge.cachedLabels = make(map[string]string)
+
+	user, err := repo.GetUserIdentity()
+	if err != nil {
+		return err
+	}
+
+	// preload all clients
+	err = ge.cacheAllClient(repo)
+	if err != nil {
+		return err
+	}
+
+	ge.defaultClient, err = ge.getClientForIdentity(user.Id())
+	if err != nil {
+		return err
+	}
+
+	creds, err := auth.List(repo, auth.WithUserId(user.Id()), auth.WithTarget(target), auth.WithKind(auth.KindToken))
+	if err != nil {
+		return err
+	}
+
+	if len(creds) == 0 {
+		return ErrMissingIdentityToken
+	}
+
+	ge.defaultToken = creds[0].(*auth.Token)
+
 	return nil
 }
 
-// getIdentityClient return a githubv4 API client configured with the access token of the given identity.
-// if no client were found it will initialize it from the known tokens map and cache it for next use
-func (ge *githubExporter) getIdentityClient(id entity.Id) (*githubv4.Client, error) {
-	client, ok := ge.identityClient[id]
-	if ok {
-		return client, nil
+func (ge *githubExporter) cacheAllClient(repo repository.RepoConfig) error {
+	creds, err := auth.List(repo, auth.WithTarget(target), auth.WithKind(auth.KindToken))
+	if err != nil {
+		return err
 	}
 
-	// get token
-	token, ok := ge.identityToken[id]
-	if !ok {
-		return nil, ErrMissingIdentityToken
+	for _, cred := range creds {
+		if _, ok := ge.identityClient[cred.UserId()]; !ok {
+			client := buildClient(creds[0].(*auth.Token))
+			ge.identityClient[cred.UserId()] = client
+		}
 	}
 
-	// create client
-	client = buildClient(token)
-	// cache client
-	ge.identityClient[id] = client
+	return nil
+}
 
-	return client, nil
+// getClientForIdentity return a githubv4 API client configured with the access token of the given identity.
+func (ge *githubExporter) getClientForIdentity(userId entity.Id) (*githubv4.Client, error) {
+	client, ok := ge.identityClient[userId]
+	if ok {
+		return client, nil
+	}
+
+	return nil, ErrMissingIdentityToken
 }
 
 // ExportAll export all event made by the current user to Github
 func (ge *githubExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ExportResult, error) {
 	out := make(chan core.ExportResult)
 
-	user, err := repo.GetUserIdentity()
-	if err != nil {
-		return nil, err
-	}
-
-	ge.identityToken[user.Id()] = ge.conf[core.ConfigKeyToken]
-
+	var err error
 	// get repository node id
 	ge.repositoryID, err = getRepositoryNodeID(
 		ctx,
+		ge.defaultToken,
 		ge.conf[keyOwner],
 		ge.conf[keyProject],
-		ge.conf[core.ConfigKeyToken],
 	)
-
-	if err != nil {
-		return nil, err
-	}
-
-	client, err := ge.getIdentityClient(user.Id())
 	if err != nil {
 		return nil, err
 	}
 
 	// query all labels
-	err = ge.cacheGithubLabels(ctx, client)
+	err = ge.cacheGithubLabels(ctx, ge.defaultClient)
 	if err != nil {
 		return nil, err
 	}
@@ -115,8 +139,8 @@ func (ge *githubExporter) ExportAll(ctx context.Context, repo *cache.RepoCache,
 	go func() {
 		defer close(out)
 
-		var allIdentitiesIds []entity.Id
-		for id := range ge.identityToken {
+		allIdentitiesIds := make([]entity.Id, 0, len(ge.identityClient))
+		for id := range ge.identityClient {
 			allIdentitiesIds = append(allIdentitiesIds, id)
 		}
 
@@ -209,7 +233,7 @@ func (ge *githubExporter) exportBug(ctx context.Context, b *cache.BugCache, sinc
 
 	} else {
 		// check that we have a token for operation author
-		client, err := ge.getIdentityClient(author.Id())
+		client, err := ge.getClientForIdentity(author.Id())
 		if err != nil {
 			// if bug is still not exported and we do not have the author stop the execution
 			out <- core.NewExportNothing(b.Id(), fmt.Sprintf("missing author token"))
@@ -262,7 +286,7 @@ func (ge *githubExporter) exportBug(ctx context.Context, b *cache.BugCache, sinc
 		}
 
 		opAuthor := op.GetAuthor()
-		client, err := ge.getIdentityClient(opAuthor.Id())
+		client, err := ge.getClientForIdentity(opAuthor.Id())
 		if err != nil {
 			continue
 		}
@@ -384,7 +408,7 @@ func (ge *githubExporter) exportBug(ctx context.Context, b *cache.BugCache, sinc
 }
 
 // getRepositoryNodeID request github api v3 to get repository node id
-func getRepositoryNodeID(ctx context.Context, owner, project, token string) (string, error) {
+func getRepositoryNodeID(ctx context.Context, token *auth.Token, owner, project string) (string, error) {
 	url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
 	client := &http.Client{}
 
@@ -394,7 +418,7 @@ func getRepositoryNodeID(ctx context.Context, owner, project, token string) (str
 	}
 
 	// need the token for private repositories
-	req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
+	req.Header.Set("Authorization", fmt.Sprintf("token %s", token.Value))
 
 	ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
 	defer cancel()
@@ -512,7 +536,7 @@ func (ge *githubExporter) createGithubLabel(ctx context.Context, label, color st
 	req = req.WithContext(ctx)
 
 	// need the token for private repositories
-	req.Header.Set("Authorization", fmt.Sprintf("token %s", ge.conf[core.ConfigKeyToken]))
+	req.Header.Set("Authorization", fmt.Sprintf("token %s", ge.defaultToken.Value))
 
 	resp, err := client.Do(req)
 	if err != nil {

bridge/github/export_test.go 🔗

@@ -14,6 +14,7 @@ import (
 	"github.com/stretchr/testify/require"
 
 	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bridge/core/auth"
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/repository"
@@ -30,7 +31,7 @@ type testCase struct {
 	numOrOp int // number of original operations
 }
 
-func testCases(t *testing.T, repo *cache.RepoCache, identity *cache.IdentityCache) []*testCase {
+func testCases(t *testing.T, repo *cache.RepoCache) []*testCase {
 	// simple bug
 	simpleBug, _, err := repo.NewBug("simple bug", "new bug")
 	require.NoError(t, err)
@@ -92,32 +93,32 @@ func testCases(t *testing.T, repo *cache.RepoCache, identity *cache.IdentityCach
 	require.NoError(t, err)
 
 	return []*testCase{
-		&testCase{
+		{
 			name:    "simple bug",
 			bug:     simpleBug,
 			numOrOp: 1,
 		},
-		&testCase{
+		{
 			name:    "bug with comments",
 			bug:     bugWithComments,
 			numOrOp: 2,
 		},
-		&testCase{
+		{
 			name:    "bug label change",
 			bug:     bugLabelChange,
 			numOrOp: 6,
 		},
-		&testCase{
+		{
 			name:    "bug with comment editions",
 			bug:     bugWithCommentEditions,
 			numOrOp: 4,
 		},
-		&testCase{
+		{
 			name:    "bug changed status",
 			bug:     bugStatusChanged,
 			numOrOp: 3,
 		},
-		&testCase{
+		{
 			name:    "bug title edited",
 			bug:     bugTitleEdited,
 			numOrOp: 2,
@@ -127,11 +128,11 @@ func testCases(t *testing.T, repo *cache.RepoCache, identity *cache.IdentityCach
 
 func TestPushPull(t *testing.T) {
 	// repo owner
-	user := os.Getenv("GITHUB_TEST_USER")
+	envUser := os.Getenv("GITHUB_TEST_USER")
 
 	// token must have 'repo' and 'delete_repo' scopes
-	token := os.Getenv("GITHUB_TOKEN_ADMIN")
-	if token == "" {
+	envToken := os.Getenv("GITHUB_TOKEN_ADMIN")
+	if envToken == "" {
 		t.Skip("Env var GITHUB_TOKEN_ADMIN missing")
 	}
 
@@ -152,35 +153,38 @@ func TestPushPull(t *testing.T) {
 	defer backend.Close()
 	interrupt.RegisterCleaner(backend.Close)
 
-	tests := testCases(t, backend, author)
+	tests := testCases(t, backend)
 
 	// generate project name
 	projectName := generateRepoName()
 
 	// create target Github repository
-	err = createRepository(projectName, token)
+	err = createRepository(projectName, envToken)
 	require.NoError(t, err)
 
 	fmt.Println("created repository", projectName)
 
 	// Make sure to remove the Github repository when the test end
 	defer func(t *testing.T) {
-		if err := deleteRepository(projectName, user, token); err != nil {
+		if err := deleteRepository(projectName, envUser, envToken); err != nil {
 			t.Fatal(err)
 		}
 		fmt.Println("deleted repository:", projectName)
 	}(t)
 
 	interrupt.RegisterCleaner(func() error {
-		return deleteRepository(projectName, user, token)
+		return deleteRepository(projectName, envUser, envToken)
 	})
 
+	token := auth.NewToken(author.Id(), envToken, target)
+	err = auth.Store(repo, token)
+	require.NoError(t, err)
+
 	// initialize exporter
 	exporter := &githubExporter{}
-	err = exporter.Init(core.Configuration{
-		keyOwner:   user,
+	err = exporter.Init(backend, core.Configuration{
+		keyOwner:   envUser,
 		keyProject: projectName,
-		keyToken:   token,
 	})
 	require.NoError(t, err)
 
@@ -206,10 +210,9 @@ func TestPushPull(t *testing.T) {
 	require.NoError(t, err)
 
 	importer := &githubImporter{}
-	err = importer.Init(core.Configuration{
-		keyOwner:   user,
+	err = importer.Init(backend, core.Configuration{
+		keyOwner:   envUser,
 		keyProject: projectName,
-		keyToken:   token,
 	})
 	require.NoError(t, err)
 

bridge/github/github.go 🔗

@@ -8,6 +8,7 @@ import (
 	"golang.org/x/oauth2"
 
 	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bridge/core/auth"
 )
 
 type Github struct{}
@@ -24,9 +25,9 @@ func (*Github) NewExporter() core.Exporter {
 	return &githubExporter{}
 }
 
-func buildClient(token string) *githubv4.Client {
+func buildClient(token *auth.Token) *githubv4.Client {
 	src := oauth2.StaticTokenSource(
-		&oauth2.Token{AccessToken: token},
+		&oauth2.Token{AccessToken: token.Value},
 	)
 	httpClient := oauth2.NewClient(context.TODO(), src)
 

bridge/github/import.go 🔗

@@ -8,6 +8,7 @@ import (
 	"github.com/shurcooL/githubv4"
 
 	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bridge/core/auth"
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/entity"
@@ -24,6 +25,9 @@ const (
 type githubImporter struct {
 	conf core.Configuration
 
+	// default user client
+	client *githubv4.Client
+
 	// iterator
 	iterator *iterator
 
@@ -31,15 +35,37 @@ type githubImporter struct {
 	out chan<- core.ImportResult
 }
 
-func (gi *githubImporter) Init(conf core.Configuration) error {
+func (gi *githubImporter) Init(repo *cache.RepoCache, conf core.Configuration) error {
 	gi.conf = conf
+
+	opts := []auth.Option{
+		auth.WithTarget(target),
+		auth.WithKind(auth.KindToken),
+	}
+
+	user, err := repo.GetUserIdentity()
+	if err == nil {
+		opts = append(opts, auth.WithUserId(user.Id()))
+	}
+
+	creds, err := auth.List(repo, opts...)
+	if err != nil {
+		return err
+	}
+
+	if len(creds) == 0 {
+		return ErrMissingIdentityToken
+	}
+
+	gi.client = buildClient(creds[0].(*auth.Token))
+
 	return nil
 }
 
 // ImportAll iterate over all the configured repository issues and ensure the creation of the
 // missing issues / timeline items / edits / label events ...
 func (gi *githubImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ImportResult, error) {
-	gi.iterator = NewIterator(ctx, 10, gi.conf[keyOwner], gi.conf[keyProject], gi.conf[core.ConfigKeyToken], since)
+	gi.iterator = NewIterator(ctx, gi.client, 10, gi.conf[keyOwner], gi.conf[keyProject], since)
 	out := make(chan core.ImportResult)
 	gi.out = out
 
@@ -494,7 +520,7 @@ func (gi *githubImporter) ensurePerson(repo *cache.RepoCache, actor *actor) (*ca
 	if err == nil {
 		return i, nil
 	}
-	if _, ok := err.(entity.ErrMultipleMatch); ok {
+	if entity.IsErrMultipleMatch(err) {
 		return nil, err
 	}
 
@@ -543,7 +569,7 @@ func (gi *githubImporter) getGhost(repo *cache.RepoCache) (*cache.IdentityCache,
 	if err == nil {
 		return i, nil
 	}
-	if _, ok := err.(entity.ErrMultipleMatch); ok {
+	if entity.IsErrMultipleMatch(err) {
 		return nil, err
 	}
 
@@ -553,12 +579,10 @@ func (gi *githubImporter) getGhost(repo *cache.RepoCache) (*cache.IdentityCache,
 		"login": githubv4.String("ghost"),
 	}
 
-	gc := buildClient(gi.conf[core.ConfigKeyToken])
-
 	ctx, cancel := context.WithTimeout(gi.iterator.ctx, defaultTimeout)
 	defer cancel()
 
-	err = gc.Query(ctx, &q, variables)
+	err = gi.client.Query(ctx, &q, variables)
 	if err != nil {
 		return nil, err
 	}

bridge/github/import_test.go 🔗

@@ -11,6 +11,7 @@ import (
 	"github.com/stretchr/testify/require"
 
 	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bridge/core/auth"
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/identity"
@@ -134,16 +135,22 @@ func Test_Importer(t *testing.T) {
 	defer backend.Close()
 	interrupt.RegisterCleaner(backend.Close)
 
-	token := os.Getenv("GITHUB_TOKEN_PRIVATE")
-	if token == "" {
+	envToken := os.Getenv("GITHUB_TOKEN_PRIVATE")
+	if envToken == "" {
 		t.Skip("Env var GITHUB_TOKEN_PRIVATE missing")
 	}
 
+	err = author.Commit(repo)
+	require.NoError(t, err)
+
+	token := auth.NewToken(author.Id(), envToken, target)
+	err = auth.Store(repo, token)
+	require.NoError(t, err)
+
 	importer := &githubImporter{}
-	err = importer.Init(core.Configuration{
+	err = importer.Init(backend, core.Configuration{
 		keyOwner:   "MichaelMure",
 		keyProject: "git-bug-test-github-bridge",
-		keyToken:   token,
 	})
 	require.NoError(t, err)
 

bridge/github/iterator.go 🔗

@@ -63,9 +63,9 @@ type iterator struct {
 }
 
 // NewIterator create and initialize a new iterator
-func NewIterator(ctx context.Context, capacity int, owner, project, token string, since time.Time) *iterator {
+func NewIterator(ctx context.Context, client *githubv4.Client, capacity int, owner, project string, since time.Time) *iterator {
 	i := &iterator{
-		gc:       buildClient(token),
+		gc:       client,
 		since:    since,
 		capacity: capacity,
 		ctx:      ctx,

bridge/gitlab/config.go 🔗

@@ -6,6 +6,7 @@ import (
 	"net/url"
 	"os"
 	"regexp"
+	"sort"
 	"strconv"
 	"strings"
 	"time"
@@ -15,6 +16,8 @@ import (
 	"github.com/xanzy/go-gitlab"
 
 	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bridge/core/auth"
+	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/colors"
@@ -24,7 +27,7 @@ var (
 	ErrBadProjectURL = errors.New("bad project url")
 )
 
-func (g *Gitlab) Configure(repo repository.RepoCommon, params core.BridgeParams) (core.Configuration, error) {
+func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) {
 	if params.Project != "" {
 		fmt.Println("warning: --project is ineffective for a gitlab bridge")
 	}
@@ -34,82 +37,77 @@ func (g *Gitlab) Configure(repo repository.RepoCommon, params core.BridgeParams)
 
 	conf := make(core.Configuration)
 	var err error
-	var url string
-	var token string
-	var tokenId entity.Id
-	var tokenObj *core.Token
 
-	if (params.Token != "" || params.TokenStdin) && params.URL == "" {
+	if (params.CredPrefix != "" || params.TokenRaw != "") && params.URL == "" {
 		return nil, fmt.Errorf("you must provide a project URL to configure this bridge with a token")
 	}
 
+	var url string
+
 	// get project url
-	if params.URL != "" {
+	switch {
+	case params.URL != "":
 		url = params.URL
-
-	} else {
-		// remote suggestions
-		remotes, err := repo.GetRemotes()
-		if err != nil {
-			return nil, errors.Wrap(err, "getting remotes")
-		}
-
+	default:
 		// terminal prompt
-		url, err = promptURL(remotes)
+		url, err = promptURL(repo)
 		if err != nil {
 			return nil, errors.Wrap(err, "url prompt")
 		}
 	}
 
-	// get user token
-	if params.Token != "" {
-		token = params.Token
-	} else if params.TokenStdin {
-		reader := bufio.NewReader(os.Stdin)
-		token, err = reader.ReadString('\n')
-		if err != nil {
-			return nil, fmt.Errorf("reading from stdin: %v", err)
-		}
-		token = strings.TrimSpace(token)
-	} else if params.TokenId != "" {
-		tokenId = entity.Id(params.TokenId)
-	} else {
-		tokenObj, err = promptTokenOptions(repo)
-		if err != nil {
-			return nil, errors.Wrap(err, "token prompt")
-		}
+	user, err := repo.GetUserIdentity()
+	if err != nil {
+		return nil, err
 	}
 
-	if token != "" {
-		tokenObj, err = core.LoadOrCreateToken(repo, target, token)
+	var cred auth.Credential
+
+	switch {
+	case params.CredPrefix != "":
+		cred, err = auth.LoadWithPrefix(repo, params.CredPrefix)
 		if err != nil {
 			return nil, err
 		}
-	} else if tokenId != "" {
-		tokenObj, err = core.LoadToken(repo, entity.Id(tokenId))
+		if cred.UserId() != user.Id() {
+			return nil, fmt.Errorf("selected credential don't match the user")
+		}
+	case params.TokenRaw != "":
+		cred = auth.NewToken(user.Id(), params.TokenRaw, target)
+	default:
+		cred, err = promptTokenOptions(repo, user.Id())
 		if err != nil {
 			return nil, err
 		}
-		if tokenObj.Target != target {
-			return nil, fmt.Errorf("token target is incompatible %s", tokenObj.Target)
-		}
+	}
+
+	token, ok := cred.(*auth.Token)
+	if !ok {
+		return nil, fmt.Errorf("the Gitlab bridge only handle token credentials")
 	}
 
 	// validate project url and get its ID
-	id, err := validateProjectURL(url, tokenObj.Value)
+	id, err := validateProjectURL(url, token)
 	if err != nil {
 		return nil, errors.Wrap(err, "project validation")
 	}
 
-	conf[keyProjectID] = strconv.Itoa(id)
-	conf[core.ConfigKeyTokenId] = tokenObj.ID().String()
 	conf[core.ConfigKeyTarget] = target
+	conf[keyProjectID] = strconv.Itoa(id)
 
 	err = g.ValidateConfig(conf)
 	if err != nil {
 		return nil, err
 	}
 
+	// don't forget to store the now known valid token
+	if !auth.IdExist(repo, cred.ID()) {
+		err = auth.Store(repo, cred)
+		if err != nil {
+			return nil, err
+		}
+	}
+
 	return conf, nil
 }
 
@@ -120,10 +118,6 @@ func (g *Gitlab) ValidateConfig(conf core.Configuration) error {
 		return fmt.Errorf("unexpected target name: %v", v)
 	}
 
-	if _, ok := conf[keyToken]; !ok {
-		return fmt.Errorf("missing %s key", keyToken)
-	}
-
 	if _, ok := conf[keyProjectID]; !ok {
 		return fmt.Errorf("missing %s key", keyProjectID)
 	}
@@ -131,19 +125,20 @@ func (g *Gitlab) ValidateConfig(conf core.Configuration) error {
 	return nil
 }
 
-func promptTokenOptions(repo repository.RepoCommon) (*core.Token, error) {
+func promptTokenOptions(repo repository.RepoConfig, userId entity.Id) (auth.Credential, error) {
 	for {
-		tokens, err := core.LoadTokensWithTarget(repo, target)
+		creds, err := auth.List(repo, auth.WithUserId(userId), auth.WithTarget(target), auth.WithKind(auth.KindToken))
 		if err != nil {
 			return nil, err
 		}
 
-		if len(tokens) == 0 {
-			token, err := promptToken()
+		// if we don't have existing token, fast-track to the token prompt
+		if len(creds) == 0 {
+			value, err := promptToken()
 			if err != nil {
 				return nil, err
 			}
-			return core.LoadOrCreateToken(repo, target, token)
+			return auth.NewToken(userId, value, target), nil
 		}
 
 		fmt.Println()
@@ -151,15 +146,16 @@ func promptTokenOptions(repo repository.RepoCommon) (*core.Token, error) {
 
 		fmt.Println()
 		fmt.Println("Existing tokens for Gitlab:")
-		for i, token := range tokens {
-			if token.Target == target {
-				fmt.Printf("[%d]: %s => %s (%s)\n",
-					i+2,
-					colors.Cyan(token.ID().Human()),
-					text.TruncateMax(token.Value, 10),
-					token.CreateTime.Format(time.RFC822),
-				)
-			}
+
+		sort.Sort(auth.ById(creds))
+		for i, cred := range creds {
+			token := cred.(*auth.Token)
+			fmt.Printf("[%d]: %s => %s (%s)\n",
+				i+2,
+				colors.Cyan(token.ID().Human()),
+				colors.Red(text.TruncateMax(token.Value, 10)),
+				token.CreateTime().Format(time.RFC822),
+			)
 		}
 
 		fmt.Println()
@@ -173,23 +169,21 @@ func promptTokenOptions(repo repository.RepoCommon) (*core.Token, error) {
 
 		line = strings.TrimSpace(line)
 		index, err := strconv.Atoi(line)
-		if err != nil || index < 1 || index > len(tokens)+1 {
+		if err != nil || index < 1 || index > len(creds)+1 {
 			fmt.Println("invalid input")
 			continue
 		}
 
-		var token string
 		switch index {
 		case 1:
-			token, err = promptToken()
+			value, err := promptToken()
 			if err != nil {
 				return nil, err
 			}
+			return auth.NewToken(userId, value, target), nil
 		default:
-			return tokens[index-2], nil
+			return creds[index-2], nil
 		}
-
-		return core.LoadOrCreateToken(repo, target, token)
 	}
 }
 
@@ -222,7 +216,13 @@ func promptToken() (string, error) {
 	}
 }
 
-func promptURL(remotes map[string]string) (string, error) {
+func promptURL(repo repository.RepoCommon) (string, error) {
+	// remote suggestions
+	remotes, err := repo.GetRemotes()
+	if err != nil {
+		return "", errors.Wrap(err, "getting remotes")
+	}
+
 	validRemotes := getValidGitlabRemoteURLs(remotes)
 	if len(validRemotes) > 0 {
 		for {
@@ -302,7 +302,7 @@ func getValidGitlabRemoteURLs(remotes map[string]string) []string {
 	return urls
 }
 
-func validateProjectURL(url, token string) (int, error) {
+func validateProjectURL(url string, token *auth.Token) (int, error) {
 	projectPath, err := getProjectPath(url)
 	if err != nil {
 		return 0, err

bridge/gitlab/export.go 🔗

@@ -10,9 +10,11 @@ import (
 	"github.com/xanzy/go-gitlab"
 
 	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bridge/core/auth"
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/repository"
 )
 
 var (
@@ -24,10 +26,7 @@ type gitlabExporter struct {
 	conf core.Configuration
 
 	// cache identities clients
-	identityClient map[string]*gitlab.Client
-
-	// map identities with their tokens
-	identityToken map[string]string
+	identityClient map[entity.Id]*gitlab.Client
 
 	// gitlab repository ID
 	repositoryID string
@@ -38,58 +37,59 @@ type gitlabExporter struct {
 }
 
 // Init .
-func (ge *gitlabExporter) Init(conf core.Configuration) error {
+func (ge *gitlabExporter) Init(repo *cache.RepoCache, conf core.Configuration) error {
 	ge.conf = conf
-	//TODO: initialize with multiple tokens
-	ge.identityToken = make(map[string]string)
-	ge.identityClient = make(map[string]*gitlab.Client)
+	ge.identityClient = make(map[entity.Id]*gitlab.Client)
 	ge.cachedOperationIDs = make(map[string]string)
 
+	// get repository node id
+	ge.repositoryID = ge.conf[keyProjectID]
+
+	// preload all clients
+	err := ge.cacheAllClient(repo)
+	if err != nil {
+		return err
+	}
+
 	return nil
 }
 
-// getIdentityClient return a gitlab v4 API client configured with the access token of the given identity.
-// if no client were found it will initialize it from the known tokens map and cache it for next use
-func (ge *gitlabExporter) getIdentityClient(id entity.Id) (*gitlab.Client, error) {
-	client, ok := ge.identityClient[id.String()]
-	if ok {
-		return client, nil
+func (ge *gitlabExporter) cacheAllClient(repo repository.RepoConfig) error {
+	creds, err := auth.List(repo, auth.WithTarget(target), auth.WithKind(auth.KindToken))
+	if err != nil {
+		return err
 	}
 
-	// get token
-	token, ok := ge.identityToken[id.String()]
-	if !ok {
-		return nil, ErrMissingIdentityToken
+	for _, cred := range creds {
+		if _, ok := ge.identityClient[cred.UserId()]; !ok {
+			client := buildClient(creds[0].(*auth.Token))
+			ge.identityClient[cred.UserId()] = client
+		}
 	}
 
-	// create client
-	client = buildClient(token)
-	// cache client
-	ge.identityClient[id.String()] = client
+	return nil
+}
+
+// getIdentityClient return a gitlab v4 API client configured with the access token of the given identity.
+func (ge *gitlabExporter) getIdentityClient(userId entity.Id) (*gitlab.Client, error) {
+	client, ok := ge.identityClient[userId]
+	if ok {
+		return client, nil
+	}
 
-	return client, nil
+	return nil, ErrMissingIdentityToken
 }
 
 // ExportAll export all event made by the current user to Gitlab
 func (ge *gitlabExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ExportResult, error) {
 	out := make(chan core.ExportResult)
 
-	user, err := repo.GetUserIdentity()
-	if err != nil {
-		return nil, err
-	}
-
-	ge.identityToken[user.Id().String()] = ge.conf[core.ConfigKeyToken]
-
-	// get repository node id
-	ge.repositoryID = ge.conf[keyProjectID]
-
 	go func() {
 		defer close(out)
 
-		allIdentitiesIds := make([]entity.Id, 0, len(ge.identityToken))
-		for id := range ge.identityToken {
-			allIdentitiesIds = append(allIdentitiesIds, entity.Id(id))
+		allIdentitiesIds := make([]entity.Id, 0, len(ge.identityClient))
+		for id := range ge.identityClient {
+			allIdentitiesIds = append(allIdentitiesIds, id)
 		}
 
 		allBugsIds := repo.AllBugsIds()

bridge/gitlab/export_test.go 🔗

@@ -14,6 +14,7 @@ import (
 	"github.com/stretchr/testify/require"
 
 	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bridge/core/auth"
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/repository"
@@ -32,7 +33,7 @@ type testCase struct {
 	numOpImp int // number of operations after import
 }
 
-func testCases(t *testing.T, repo *cache.RepoCache, identity *cache.IdentityCache) []*testCase {
+func testCases(t *testing.T, repo *cache.RepoCache) []*testCase {
 	// simple bug
 	simpleBug, _, err := repo.NewBug("simple bug", "new bug")
 	require.NoError(t, err)
@@ -135,8 +136,8 @@ func testCases(t *testing.T, repo *cache.RepoCache, identity *cache.IdentityCach
 
 func TestPushPull(t *testing.T) {
 	// token must have 'repo' and 'delete_repo' scopes
-	token := os.Getenv("GITLAB_API_TOKEN")
-	if token == "" {
+	envToken := os.Getenv("GITLAB_API_TOKEN")
+	if envToken == "" {
 		t.Skip("Env var GITLAB_API_TOKEN missing")
 	}
 
@@ -157,7 +158,11 @@ func TestPushPull(t *testing.T) {
 	defer backend.Close()
 	interrupt.RegisterCleaner(backend.Close)
 
-	tests := testCases(t, backend, author)
+	tests := testCases(t, backend)
+
+	token := auth.NewToken(author.Id(), envToken, target)
+	err = auth.Store(repo, token)
+	require.NoError(t, err)
 
 	// generate project name
 	projectName := generateRepoName()
@@ -182,9 +187,8 @@ func TestPushPull(t *testing.T) {
 
 	// initialize exporter
 	exporter := &gitlabExporter{}
-	err = exporter.Init(core.Configuration{
+	err = exporter.Init(backend, core.Configuration{
 		keyProjectID: strconv.Itoa(projectID),
-		keyToken:     token,
 	})
 	require.NoError(t, err)
 
@@ -210,9 +214,8 @@ func TestPushPull(t *testing.T) {
 	require.NoError(t, err)
 
 	importer := &gitlabImporter{}
-	err = importer.Init(core.Configuration{
+	err = importer.Init(backend, core.Configuration{
 		keyProjectID: strconv.Itoa(projectID),
-		keyToken:     token,
 	})
 	require.NoError(t, err)
 
@@ -276,7 +279,7 @@ func generateRepoName() string {
 }
 
 // create repository need a token with scope 'repo'
-func createRepository(ctx context.Context, name, token string) (int, error) {
+func createRepository(ctx context.Context, name string, token *auth.Token) (int, error) {
 	client := buildClient(token)
 	project, _, err := client.Projects.CreateProject(
 		&gitlab.CreateProjectOptions{
@@ -292,7 +295,7 @@ func createRepository(ctx context.Context, name, token string) (int, error) {
 }
 
 // delete repository need a token with scope 'delete_repo'
-func deleteRepository(ctx context.Context, project int, token string) error {
+func deleteRepository(ctx context.Context, project int, token *auth.Token) error {
 	client := buildClient(token)
 	_, err := client.Projects.DeleteProject(project, gitlab.WithContext(ctx))
 	return err

bridge/gitlab/gitlab.go 🔗

@@ -7,6 +7,7 @@ import (
 	"github.com/xanzy/go-gitlab"
 
 	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bridge/core/auth"
 )
 
 const (
@@ -18,7 +19,6 @@ const (
 	metaKeyGitlabProject = "gitlab-project-id"
 
 	keyProjectID = "project-id"
-	keyToken     = "token"
 
 	defaultTimeout = 60 * time.Second
 )
@@ -37,10 +37,10 @@ func (*Gitlab) NewExporter() core.Exporter {
 	return &gitlabExporter{}
 }
 
-func buildClient(token string) *gitlab.Client {
+func buildClient(token *auth.Token) *gitlab.Client {
 	client := &http.Client{
 		Timeout: defaultTimeout,
 	}
 
-	return gitlab.NewClient(client, token)
+	return gitlab.NewClient(client, token.Value)
 }

bridge/gitlab/import.go 🔗

@@ -9,6 +9,7 @@ import (
 	"github.com/xanzy/go-gitlab"
 
 	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bridge/core/auth"
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/entity"
@@ -19,6 +20,9 @@ import (
 type gitlabImporter struct {
 	conf core.Configuration
 
+	// default user client
+	client *gitlab.Client
+
 	// iterator
 	iterator *iterator
 
@@ -26,15 +30,37 @@ type gitlabImporter struct {
 	out chan<- core.ImportResult
 }
 
-func (gi *gitlabImporter) Init(conf core.Configuration) error {
+func (gi *gitlabImporter) Init(repo *cache.RepoCache, conf core.Configuration) error {
 	gi.conf = conf
+
+	opts := []auth.Option{
+		auth.WithTarget(target),
+		auth.WithKind(auth.KindToken),
+	}
+
+	user, err := repo.GetUserIdentity()
+	if err == nil {
+		opts = append(opts, auth.WithUserId(user.Id()))
+	}
+
+	creds, err := auth.List(repo, opts...)
+	if err != nil {
+		return err
+	}
+
+	if len(creds) == 0 {
+		return ErrMissingIdentityToken
+	}
+
+	gi.client = buildClient(creds[0].(*auth.Token))
+
 	return nil
 }
 
 // ImportAll iterate over all the configured repository issues (notes) and ensure the creation
 // of the missing issues / comments / label events / title changes ...
 func (gi *gitlabImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ImportResult, error) {
-	gi.iterator = NewIterator(ctx, 10, gi.conf[keyProjectID], gi.conf[core.ConfigKeyToken], since)
+	gi.iterator = NewIterator(ctx, gi.client, 10, gi.conf[keyProjectID], since)
 	out := make(chan core.ImportResult)
 	gi.out = out
 
@@ -357,13 +383,11 @@ func (gi *gitlabImporter) ensurePerson(repo *cache.RepoCache, id int) (*cache.Id
 	if err == nil {
 		return i, nil
 	}
-	if _, ok := err.(entity.ErrMultipleMatch); ok {
+	if entity.IsErrMultipleMatch(err) {
 		return nil, err
 	}
 
-	client := buildClient(gi.conf["token"])
-
-	user, _, err := client.Users.GetUser(id)
+	user, _, err := gi.client.Users.GetUser(id)
 	if err != nil {
 		return nil, err
 	}

bridge/gitlab/import_test.go 🔗

@@ -11,6 +11,7 @@ import (
 	"github.com/stretchr/testify/require"
 
 	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bridge/core/auth"
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/identity"
@@ -83,8 +84,8 @@ func TestImport(t *testing.T) {
 	defer backend.Close()
 	interrupt.RegisterCleaner(backend.Close)
 
-	token := os.Getenv("GITLAB_API_TOKEN")
-	if token == "" {
+	envToken := os.Getenv("GITLAB_API_TOKEN")
+	if envToken == "" {
 		t.Skip("Env var GITLAB_API_TOKEN missing")
 	}
 
@@ -93,10 +94,16 @@ func TestImport(t *testing.T) {
 		t.Skip("Env var GITLAB_PROJECT_ID missing")
 	}
 
+	err = author.Commit(repo)
+	require.NoError(t, err)
+
+	token := auth.NewToken(author.Id(), envToken, target)
+	err = auth.Store(repo, token)
+	require.NoError(t, err)
+
 	importer := &gitlabImporter{}
-	err = importer.Init(core.Configuration{
+	err = importer.Init(backend, core.Configuration{
 		keyProjectID: projectID,
-		keyToken:     token,
 	})
 	require.NoError(t, err)
 

bridge/gitlab/iterator.go 🔗

@@ -71,9 +71,9 @@ type iterator struct {
 }
 
 // NewIterator create a new iterator
-func NewIterator(ctx context.Context, capacity int, projectID, token string, since time.Time) *iterator {
+func NewIterator(ctx context.Context, client *gitlab.Client, capacity int, projectID string, since time.Time) *iterator {
 	return &iterator{
-		gc:       buildClient(token),
+		gc:       client,
 		project:  projectID,
 		since:    since,
 		capacity: capacity,

bridge/launchpad/config.go 🔗

@@ -11,7 +11,7 @@ import (
 	"time"
 
 	"github.com/MichaelMure/git-bug/bridge/core"
-	"github.com/MichaelMure/git-bug/repository"
+	"github.com/MichaelMure/git-bug/cache"
 )
 
 var ErrBadProjectURL = errors.New("bad Launchpad project URL")
@@ -22,9 +22,9 @@ const (
 	defaultTimeout = 60 * time.Second
 )
 
-func (l *Launchpad) Configure(repo repository.RepoCommon, params core.BridgeParams) (core.Configuration, error) {
-	if params.Token != "" {
-		fmt.Println("warning: --token is ineffective for a Launchpad bridge")
+func (l *Launchpad) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) {
+	if params.TokenRaw != "" {
+		fmt.Println("warning: token params are ineffective for a Launchpad bridge")
 	}
 	if params.Owner != "" {
 		fmt.Println("warning: --owner is ineffective for a Launchpad bridge")
@@ -34,22 +34,19 @@ func (l *Launchpad) Configure(repo repository.RepoCommon, params core.BridgePara
 	var err error
 	var project string
 
-	if params.Project != "" {
+	switch {
+	case params.Project != "":
 		project = params.Project
-
-	} else if params.URL != "" {
+	case params.URL != "":
 		// get project name from url
 		project, err = splitURL(params.URL)
-		if err != nil {
-			return nil, err
-		}
-
-	} else {
+	default:
 		// get project name from terminal prompt
 		project, err = promptProjectName()
-		if err != nil {
-			return nil, err
-		}
+	}
+
+	if err != nil {
+		return nil, err
 	}
 
 	// verify project
@@ -61,8 +58,8 @@ func (l *Launchpad) Configure(repo repository.RepoCommon, params core.BridgePara
 		return nil, fmt.Errorf("project doesn't exist")
 	}
 
-	conf[keyProject] = project
 	conf[core.ConfigKeyTarget] = target
+	conf[keyProject] = project
 
 	err = l.ValidateConfig(conf)
 	if err != nil {
@@ -73,12 +70,14 @@ func (l *Launchpad) Configure(repo repository.RepoCommon, params core.BridgePara
 }
 
 func (*Launchpad) ValidateConfig(conf core.Configuration) error {
-	if _, ok := conf[keyProject]; !ok {
-		return fmt.Errorf("missing %s key", keyProject)
+	if v, ok := conf[core.ConfigKeyTarget]; !ok {
+		return fmt.Errorf("missing %s key", core.ConfigKeyTarget)
+	} else if v != target {
+		return fmt.Errorf("unexpected target name: %v", v)
 	}
 
-	if _, ok := conf[core.ConfigKeyTarget]; !ok {
-		return fmt.Errorf("missing %s key", core.ConfigKeyTarget)
+	if _, ok := conf[keyProject]; !ok {
+		return fmt.Errorf("missing %s key", keyProject)
 	}
 
 	return nil

bridge/launchpad/import.go 🔗

@@ -15,7 +15,7 @@ type launchpadImporter struct {
 	conf core.Configuration
 }
 
-func (li *launchpadImporter) Init(conf core.Configuration) error {
+func (li *launchpadImporter) Init(repo *cache.RepoCache, conf core.Configuration) error {
 	li.conf = conf
 	return nil
 }
@@ -31,7 +31,7 @@ func (li *launchpadImporter) ensurePerson(repo *cache.RepoCache, owner LPPerson)
 	if err == nil {
 		return i, nil
 	}
-	if _, ok := err.(entity.ErrMultipleMatch); ok {
+	if entity.IsErrMultipleMatch(err) {
 		return nil, err
 	}
 

commands/add.go 🔗

@@ -57,7 +57,7 @@ func runAddBug(cmd *cobra.Command, args []string) error {
 var addCmd = &cobra.Command{
 	Use:     "add",
 	Short:   "Create a new bug.",
-	PreRunE: loadRepo,
+	PreRunE: loadRepoEnsureUser,
 	RunE:    runAddBug,
 }
 

commands/bridge_auth.go 🔗

@@ -7,36 +7,56 @@ import (
 
 	text "github.com/MichaelMure/go-term-text"
 
-	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bridge/core/auth"
+	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/util/colors"
+	"github.com/MichaelMure/git-bug/util/interrupt"
 )
 
 func runBridgeAuth(cmd *cobra.Command, args []string) error {
-	tokens, err := core.ListTokens(repo)
+	backend, err := cache.NewRepoCache(repo)
 	if err != nil {
 		return err
 	}
+	defer backend.Close()
+	interrupt.RegisterCleaner(backend.Close)
 
-	for _, token := range tokens {
-		token, err := core.LoadToken(repo, token)
+	creds, err := auth.List(backend)
+	if err != nil {
+		return err
+	}
+
+	defaultUser, _ := backend.GetUserIdentity()
+
+	for _, cred := range creds {
+		targetFmt := text.LeftPadMaxLine(cred.Target(), 10, 0)
+
+		var value string
+		switch cred := cred.(type) {
+		case *auth.Token:
+			value = cred.Value
+		}
+
+		user, err := backend.ResolveIdentity(cred.UserId())
 		if err != nil {
 			return err
 		}
-		printToken(token)
-	}
+		userFmt := user.DisplayName()
 
-	return nil
-}
+		if cred.UserId() == defaultUser.Id() {
+			userFmt = colors.Red(userFmt)
+		}
 
-func printToken(token *core.Token) {
-	targetFmt := text.LeftPadMaxLine(token.Target, 10, 0)
+		fmt.Printf("%s %s %s %s %s\n",
+			colors.Cyan(cred.ID().Human()),
+			colors.Yellow(targetFmt),
+			colors.Magenta(cred.Kind()),
+			userFmt,
+			value,
+		)
+	}
 
-	fmt.Printf("%s %s %s %s\n",
-		colors.Cyan(token.ID().Human()),
-		colors.Yellow(targetFmt),
-		colors.Magenta("token"),
-		token.Value,
-	)
+	return nil
 }
 
 var bridgeAuthCmd = &cobra.Command{

commands/bridge_auth_add.go → commands/bridge_auth_addtoken.go 🔗

@@ -12,6 +12,8 @@ import (
 
 	"github.com/MichaelMure/git-bug/bridge"
 	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bridge/core/auth"
+	"github.com/MichaelMure/git-bug/identity"
 )
 
 var (
@@ -22,7 +24,7 @@ func runBridgeTokenAdd(cmd *cobra.Command, args []string) error {
 	var value string
 
 	if bridgeAuthAddTokenTarget == "" {
-		return fmt.Errorf("auth target is required")
+		return fmt.Errorf("flag --target is required")
 	}
 
 	if !core.TargetExist(bridgeAuthAddTokenTarget) {
@@ -44,12 +46,17 @@ func runBridgeTokenAdd(cmd *cobra.Command, args []string) error {
 		value = strings.TrimSuffix(raw, "\n")
 	}
 
-	token := core.NewToken(value, bridgeAuthAddTokenTarget)
+	user, err := identity.GetUserIdentity(repo)
+	if err != nil {
+		return err
+	}
+
+	token := auth.NewToken(user.Id(), value, bridgeAuthAddTokenTarget)
 	if err := token.Validate(); err != nil {
 		return errors.Wrap(err, "invalid token")
 	}
 
-	err := core.StoreToken(repo, token)
+	err = auth.Store(repo, token)
 	if err != nil {
 		return err
 	}
@@ -61,7 +68,7 @@ func runBridgeTokenAdd(cmd *cobra.Command, args []string) error {
 var bridgeAuthAddTokenCmd = &cobra.Command{
 	Use:     "add-token [<token>]",
 	Short:   "Store a new token",
-	PreRunE: loadRepo,
+	PreRunE: loadRepoEnsureUser,
 	RunE:    runBridgeTokenAdd,
 	Args:    cobra.MaximumNArgs(1),
 }

commands/bridge_auth_rm.go 🔗

@@ -5,21 +5,21 @@ import (
 
 	"github.com/spf13/cobra"
 
-	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bridge/core/auth"
 )
 
 func runBridgeAuthRm(cmd *cobra.Command, args []string) error {
-	token, err := core.LoadTokenPrefix(repo, args[0])
+	cred, err := auth.LoadWithPrefix(repo, args[0])
 	if err != nil {
 		return err
 	}
 
-	err = core.RemoveToken(repo, token.ID())
+	err = auth.Remove(repo, cred.ID())
 	if err != nil {
 		return err
 	}
 
-	fmt.Printf("token %s removed\n", token.ID())
+	fmt.Printf("credential %s removed\n", cred.ID())
 	return nil
 }
 

commands/bridge_auth_show.go 🔗

@@ -6,20 +6,24 @@ import (
 
 	"github.com/spf13/cobra"
 
-	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bridge/core/auth"
 )
 
 func runBridgeAuthShow(cmd *cobra.Command, args []string) error {
-	token, err := core.LoadTokenPrefix(repo, args[0])
+	cred, err := auth.LoadWithPrefix(repo, args[0])
 	if err != nil {
 		return err
 	}
 
-	fmt.Printf("Id: %s\n", token.ID())
-	fmt.Printf("Target: %s\n", token.Target)
-	fmt.Printf("Type: token\n")
-	fmt.Printf("Value: %s\n", token.Value)
-	fmt.Printf("Creation: %s\n", token.CreateTime.Format(time.RFC822))
+	fmt.Printf("Id: %s\n", cred.ID())
+	fmt.Printf("Target: %s\n", cred.Target())
+	fmt.Printf("Kind: %s\n", cred.Kind())
+	fmt.Printf("Creation: %s\n", cred.CreateTime().Format(time.RFC822))
+
+	switch cred := cred.(type) {
+	case *auth.Token:
+		fmt.Printf("Value: %s\n", cred.Value)
+	}
 
 	return nil
 }

commands/bridge_configure.go 🔗

@@ -11,6 +11,7 @@ import (
 
 	"github.com/MichaelMure/git-bug/bridge"
 	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bridge/core/auth"
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/interrupt"
@@ -21,9 +22,11 @@ const (
 )
 
 var (
-	bridgeConfigureName   string
-	bridgeConfigureTarget string
-	bridgeParams          core.BridgeParams
+	bridgeConfigureName       string
+	bridgeConfigureTarget     string
+	bridgeConfigureParams     core.BridgeParams
+	bridgeConfigureToken      string
+	bridgeConfigureTokenStdin bool
 )
 
 func runBridgeConfigure(cmd *cobra.Command, args []string) error {
@@ -34,9 +37,28 @@ func runBridgeConfigure(cmd *cobra.Command, args []string) error {
 	defer backend.Close()
 	interrupt.RegisterCleaner(backend.Close)
 
-	if (bridgeParams.TokenStdin || bridgeParams.Token != "" || bridgeParams.TokenId != "") &&
+	if (bridgeConfigureTokenStdin || bridgeConfigureToken != "" || bridgeConfigureParams.CredPrefix != "") &&
 		(bridgeConfigureName == "" || bridgeConfigureTarget == "") {
-		return fmt.Errorf("you must provide a bridge name and target to configure a bridge with a token")
+		return fmt.Errorf("you must provide a bridge name and target to configure a bridge with a credential")
+	}
+
+	// early fail
+	if bridgeConfigureParams.CredPrefix != "" {
+		if _, err := auth.LoadWithPrefix(repo, bridgeConfigureParams.CredPrefix); err != nil {
+			return err
+		}
+	}
+
+	switch {
+	case bridgeConfigureTokenStdin:
+		reader := bufio.NewReader(os.Stdin)
+		token, err := reader.ReadString('\n')
+		if err != nil {
+			return fmt.Errorf("reading from stdin: %v", err)
+		}
+		bridgeConfigureParams.TokenRaw = strings.TrimSpace(token)
+	case bridgeConfigureToken != "":
+		bridgeConfigureParams.TokenRaw = bridgeConfigureToken
 	}
 
 	if bridgeConfigureTarget == "" {
@@ -58,7 +80,7 @@ func runBridgeConfigure(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	err = b.Configure(bridgeParams)
+	err = b.Configure(bridgeConfigureParams)
 	if err != nil {
 		return err
 	}
@@ -94,7 +116,7 @@ func promptTarget() (string, error) {
 	}
 }
 
-func promptName(repo repository.RepoCommon) (string, error) {
+func promptName(repo repository.RepoConfig) (string, error) {
 	defaultExist := core.BridgeExist(repo, defaultName)
 
 	for {
@@ -184,7 +206,7 @@ git bug bridge configure \
     --target=github \
     --url=https://github.com/michaelmure/git-bug \
     --token=$(TOKEN)`,
-	PreRunE: loadRepo,
+	PreRunE: loadRepoEnsureUser,
 	RunE:    runBridgeConfigure,
 }
 
@@ -193,11 +215,11 @@ func init() {
 	bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureName, "name", "n", "", "A distinctive name to identify the bridge")
 	bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureTarget, "target", "t", "",
 		fmt.Sprintf("The target of the bridge. Valid values are [%s]", strings.Join(bridge.Targets(), ",")))
-	bridgeConfigureCmd.Flags().StringVarP(&bridgeParams.URL, "url", "u", "", "The URL of the target repository")
-	bridgeConfigureCmd.Flags().StringVarP(&bridgeParams.Owner, "owner", "o", "", "The owner of the target repository")
-	bridgeConfigureCmd.Flags().StringVarP(&bridgeParams.Token, "token", "T", "", "The authentication token for the API")
-	bridgeConfigureCmd.Flags().StringVarP(&bridgeParams.TokenId, "token-id", "i", "", "The authentication token identifier for the API")
-	bridgeConfigureCmd.Flags().BoolVar(&bridgeParams.TokenStdin, "token-stdin", false, "Will read the token from stdin and ignore --token")
-	bridgeConfigureCmd.Flags().StringVarP(&bridgeParams.Project, "project", "p", "", "The name of the target repository")
+	bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.URL, "url", "u", "", "The URL of the target repository")
+	bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.Owner, "owner", "o", "", "The owner of the target repository")
+	bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.CredPrefix, "credential", "c", "", "The identifier or prefix of an already known credential for the API (see \"git-bug bridge auth\")")
+	bridgeConfigureCmd.Flags().StringVar(&bridgeConfigureToken, "token", "", "A raw authentication token for the API")
+	bridgeConfigureCmd.Flags().BoolVar(&bridgeConfigureTokenStdin, "token-stdin", false, "Will read the token from stdin and ignore --token")
+	bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.Project, "project", "p", "", "The name of the target repository")
 	bridgeConfigureCmd.Flags().SortFlags = false
 }

commands/bridge_pull.go 🔗

@@ -128,7 +128,7 @@ func parseSince(since string) (time.Time, error) {
 var bridgePullCmd = &cobra.Command{
 	Use:     "pull [<name>]",
 	Short:   "Pull updates.",
-	PreRunE: loadRepo,
+	PreRunE: loadRepoEnsureUser,
 	RunE:    runBridgePull,
 	Args:    cobra.MaximumNArgs(1),
 }

commands/bridge_push.go 🔗

@@ -90,7 +90,7 @@ func runBridgePush(cmd *cobra.Command, args []string) error {
 var bridgePushCmd = &cobra.Command{
 	Use:     "push [<name>]",
 	Short:   "Push updates.",
-	PreRunE: loadRepo,
+	PreRunE: loadRepoEnsureUser,
 	RunE:    runBridgePush,
 	Args:    cobra.MaximumNArgs(1),
 }

commands/comment_add.go 🔗

@@ -57,7 +57,7 @@ func runCommentAdd(cmd *cobra.Command, args []string) error {
 var commentAddCmd = &cobra.Command{
 	Use:     "add [<id>]",
 	Short:   "Add a new comment to a bug.",
-	PreRunE: loadRepo,
+	PreRunE: loadRepoEnsureUser,
 	RunE:    runCommentAdd,
 }
 

commands/label_add.go 🔗

@@ -38,7 +38,7 @@ func runLabelAdd(cmd *cobra.Command, args []string) error {
 var labelAddCmd = &cobra.Command{
 	Use:     "add [<id>] <label>[...]",
 	Short:   "Add a label to a bug.",
-	PreRunE: loadRepo,
+	PreRunE: loadRepoEnsureUser,
 	RunE:    runLabelAdd,
 }
 

commands/root.go 🔗

@@ -84,16 +84,10 @@ func loadRepoEnsureUser(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	set, err := identity.IsUserIdentitySet(repo)
+	_, err = identity.GetUserIdentity(repo)
 	if err != nil {
 		return err
 	}
 
-	if !set {
-		// Print the error directly to not confuse a user
-		_, _ = fmt.Fprintln(os.Stderr, identity.ErrNoIdentitySet.Error())
-		os.Exit(-1)
-	}
-
 	return nil
 }

commands/status_close.go 🔗

@@ -31,7 +31,7 @@ func runStatusClose(cmd *cobra.Command, args []string) error {
 var closeCmd = &cobra.Command{
 	Use:     "close [<id>]",
 	Short:   "Mark a bug as closed.",
-	PreRunE: loadRepo,
+	PreRunE: loadRepoEnsureUser,
 	RunE:    runStatusClose,
 }
 

commands/status_open.go 🔗

@@ -31,7 +31,7 @@ func runStatusOpen(cmd *cobra.Command, args []string) error {
 var openCmd = &cobra.Command{
 	Use:     "open [<id>]",
 	Short:   "Mark a bug as open.",
-	PreRunE: loadRepo,
+	PreRunE: loadRepoEnsureUser,
 	RunE:    runStatusOpen,
 }
 

commands/title_edit.go 🔗

@@ -55,7 +55,7 @@ func runTitleEdit(cmd *cobra.Command, args []string) error {
 var titleEditCmd = &cobra.Command{
 	Use:     "edit [<id>]",
 	Short:   "Edit a title of a bug.",
-	PreRunE: loadRepo,
+	PreRunE: loadRepoEnsureUser,
 	RunE:    runTitleEdit,
 }
 

commands/user.go 🔗

@@ -4,9 +4,10 @@ import (
 	"errors"
 	"fmt"
 
+	"github.com/spf13/cobra"
+
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/util/interrupt"
-	"github.com/spf13/cobra"
 )
 
 var (
@@ -84,7 +85,7 @@ func runUser(cmd *cobra.Command, args []string) error {
 var userCmd = &cobra.Command{
 	Use:     "user [<user-id>]",
 	Short:   "Display or change the user identity.",
-	PreRunE: loadRepo,
+	PreRunE: loadRepoEnsureUser,
 	RunE:    runUser,
 }
 

commands/user_create.go 🔗

@@ -18,10 +18,6 @@ func runUserCreate(cmd *cobra.Command, args []string) error {
 	defer backend.Close()
 	interrupt.RegisterCleaner(backend.Close)
 
-	_, _ = fmt.Fprintf(os.Stderr, "Before creating a new identity, please be aware that "+
-		"you can also use an already existing one using \"git bug user adopt\". As an example, "+
-		"you can do that if your identity has already been created by an importer.\n\n")
-
 	preName, err := backend.GetUserName()
 	if err != nil {
 		return err

commands/webui.go 🔗

@@ -249,7 +249,7 @@ var webUICmd = &cobra.Command{
 Available git config:
   git-bug.webui.open [bool]: control the automatic opening of the web UI in the default browser
 `,
-	PreRunE: loadRepo,
+	PreRunE: loadRepoEnsureUser,
 	RunE:    runWebUI,
 }
 

doc/man/git-bug-bridge-configure.1 🔗

@@ -44,12 +44,12 @@ Token configuration can be directly passed with the \-\-token flag or in the ter
     The owner of the target repository
 
 .PP
-\fB\-T\fP, \fB\-\-token\fP=""
-    The authentication token for the API
+\fB\-c\fP, \fB\-\-credential\fP=""
+    The identifier or prefix of an already known credential for the API (see "git\-bug bridge auth")
 
 .PP
-\fB\-i\fP, \fB\-\-token\-id\fP=""
-    The authentication token identifier for the API
+\fB\-\-token\fP=""
+    A raw authentication token for the API
 
 .PP
 \fB\-\-token\-stdin\fP[=false]

doc/md/git-bug_bridge_configure.md 🔗

@@ -70,15 +70,15 @@ git bug bridge configure \
 ### Options
 
 ```
-  -n, --name string       A distinctive name to identify the bridge
-  -t, --target string     The target of the bridge. Valid values are [github,gitlab,launchpad-preview]
-  -u, --url string        The URL of the target repository
-  -o, --owner string      The owner of the target repository
-  -T, --token string      The authentication token for the API
-  -i, --token-id string   The authentication token identifier for the API
-      --token-stdin       Will read the token from stdin and ignore --token
-  -p, --project string    The name of the target repository
-  -h, --help              help for configure
+  -n, --name string         A distinctive name to identify the bridge
+  -t, --target string       The target of the bridge. Valid values are [github,gitlab,launchpad-preview]
+  -u, --url string          The URL of the target repository
+  -o, --owner string        The owner of the target repository
+  -c, --credential string   The identifier or prefix of an already known credential for the API (see "git-bug bridge auth")
+      --token string        A raw authentication token for the API
+      --token-stdin         Will read the token from stdin and ignore --token
+  -p, --project string      The name of the target repository
+  -h, --help                help for configure
 ```
 
 ### SEE ALSO

entity/err.go 🔗

@@ -25,3 +25,8 @@ func (e ErrMultipleMatch) Error() string {
 		e.entityType,
 		strings.Join(matching, "\n"))
 }
+
+func IsErrMultipleMatch(err error) bool {
+	_, ok := err.(*ErrMultipleMatch)
+	return ok
+}

identity/identity.go 🔗

@@ -23,7 +23,9 @@ const versionEntryName = "version"
 const identityConfigKey = "git-bug.identity"
 
 var ErrNonFastForwardMerge = errors.New("non fast-forward identity merge")
-var ErrNoIdentitySet = errors.New("to interact with bugs, an identity first needs to be created using \"git bug user create\" or \"git bug user adopt\"")
+var ErrNoIdentitySet = errors.New("No identity is set.\n" +
+	"To interact with bugs, an identity first needs to be created using " +
+	"\"git bug user create\"")
 var ErrMultipleIdentitiesSet = errors.New("multiple user identities set")
 
 var _ Interface = &Identity{}
@@ -218,22 +220,8 @@ func NewFromGitUser(repo repository.Repo) (*Identity, error) {
 	return NewIdentity(name, email), nil
 }
 
-// IsUserIdentitySet tell if the user identity is correctly set.
-func IsUserIdentitySet(repo repository.RepoCommon) (bool, error) {
-	configs, err := repo.LocalConfig().ReadAll(identityConfigKey)
-	if err != nil {
-		return false, err
-	}
-
-	if len(configs) > 1 {
-		return false, ErrMultipleIdentitiesSet
-	}
-
-	return len(configs) == 1, nil
-}
-
 // SetUserIdentity store the user identity's id in the git config
-func SetUserIdentity(repo repository.RepoCommon, identity *Identity) error {
+func SetUserIdentity(repo repository.RepoConfig, identity *Identity) error {
 	return repo.LocalConfig().StoreString(identityConfigKey, identity.Id().String())
 }
 

misc/bash_completion/git-bug 🔗

@@ -404,14 +404,13 @@ _git-bug_bridge_configure()
     two_word_flags+=("--owner")
     two_word_flags+=("-o")
     local_nonpersistent_flags+=("--owner=")
+    flags+=("--credential=")
+    two_word_flags+=("--credential")
+    two_word_flags+=("-c")
+    local_nonpersistent_flags+=("--credential=")
     flags+=("--token=")
     two_word_flags+=("--token")
-    two_word_flags+=("-T")
     local_nonpersistent_flags+=("--token=")
-    flags+=("--token-id=")
-    two_word_flags+=("--token-id")
-    two_word_flags+=("-i")
-    local_nonpersistent_flags+=("--token-id=")
     flags+=("--token-stdin")
     local_nonpersistent_flags+=("--token-stdin")
     flags+=("--project=")

misc/powershell_completion/git-bug 🔗

@@ -81,10 +81,9 @@ Register-ArgumentCompleter -Native -CommandName 'git-bug' -ScriptBlock {
             [CompletionResult]::new('--url', 'url', [CompletionResultType]::ParameterName, 'The URL of the target repository')
             [CompletionResult]::new('-o', 'o', [CompletionResultType]::ParameterName, 'The owner of the target repository')
             [CompletionResult]::new('--owner', 'owner', [CompletionResultType]::ParameterName, 'The owner of the target repository')
-            [CompletionResult]::new('-T', 'T', [CompletionResultType]::ParameterName, 'The authentication token for the API')
-            [CompletionResult]::new('--token', 'token', [CompletionResultType]::ParameterName, 'The authentication token for the API')
-            [CompletionResult]::new('-i', 'i', [CompletionResultType]::ParameterName, 'The authentication token identifier for the API')
-            [CompletionResult]::new('--token-id', 'token-id', [CompletionResultType]::ParameterName, 'The authentication token identifier for the API')
+            [CompletionResult]::new('-c', 'c', [CompletionResultType]::ParameterName, 'The identifier or prefix of an already known credential for the API (see "git-bug bridge auth")')
+            [CompletionResult]::new('--credential', 'credential', [CompletionResultType]::ParameterName, 'The identifier or prefix of an already known credential for the API (see "git-bug bridge auth")')
+            [CompletionResult]::new('--token', 'token', [CompletionResultType]::ParameterName, 'A raw authentication token for the API')
             [CompletionResult]::new('--token-stdin', 'token-stdin', [CompletionResultType]::ParameterName, 'Will read the token from stdin and ignore --token')
             [CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'The name of the target repository')
             [CompletionResult]::new('--project', 'project', [CompletionResultType]::ParameterName, 'The name of the target repository')

misc/zsh_completion/git-bug 🔗

@@ -194,8 +194,8 @@ function _git-bug_bridge_configure {
     '(-t --target)'{-t,--target}'[The target of the bridge. Valid values are [github,gitlab,launchpad-preview]]:' \
     '(-u --url)'{-u,--url}'[The URL of the target repository]:' \
     '(-o --owner)'{-o,--owner}'[The owner of the target repository]:' \
-    '(-T --token)'{-T,--token}'[The authentication token for the API]:' \
-    '(-i --token-id)'{-i,--token-id}'[The authentication token identifier for the API]:' \
+    '(-c --credential)'{-c,--credential}'[The identifier or prefix of an already known credential for the API (see "git-bug bridge auth")]:' \
+    '--token[A raw authentication token for the API]:' \
     '--token-stdin[Will read the token from stdin and ignore --token]' \
     '(-p --project)'{-p,--project}'[The name of the target repository]:'
 }

repository/config_mem.go 🔗

@@ -6,30 +6,32 @@ import (
 	"time"
 )
 
-var _ Config = &memConfig{}
+var _ Config = &MemConfig{}
 
-type memConfig struct {
+type MemConfig struct {
 	config map[string]string
 }
 
-func newMemConfig(config map[string]string) *memConfig {
-	return &memConfig{config: config}
+func NewMemConfig() *MemConfig {
+	return &MemConfig{
+		config: make(map[string]string),
+	}
 }
 
-func (mc *memConfig) StoreString(key, value string) error {
+func (mc *MemConfig) StoreString(key, value string) error {
 	mc.config[key] = value
 	return nil
 }
 
-func (mc *memConfig) StoreBool(key string, value bool) error {
+func (mc *MemConfig) StoreBool(key string, value bool) error {
 	return mc.StoreString(key, strconv.FormatBool(value))
 }
 
-func (mc *memConfig) StoreTimestamp(key string, value time.Time) error {
+func (mc *MemConfig) StoreTimestamp(key string, value time.Time) error {
 	return mc.StoreString(key, strconv.Itoa(int(value.Unix())))
 }
 
-func (mc *memConfig) ReadAll(keyPrefix string) (map[string]string, error) {
+func (mc *MemConfig) ReadAll(keyPrefix string) (map[string]string, error) {
 	result := make(map[string]string)
 	for key, val := range mc.config {
 		if strings.HasPrefix(key, keyPrefix) {
@@ -39,7 +41,7 @@ func (mc *memConfig) ReadAll(keyPrefix string) (map[string]string, error) {
 	return result, nil
 }
 
-func (mc *memConfig) ReadString(key string) (string, error) {
+func (mc *MemConfig) ReadString(key string) (string, error) {
 	// unlike git, the mock can only store one value for the same key
 	val, ok := mc.config[key]
 	if !ok {
@@ -49,7 +51,7 @@ func (mc *memConfig) ReadString(key string) (string, error) {
 	return val, nil
 }
 
-func (mc *memConfig) ReadBool(key string) (bool, error) {
+func (mc *MemConfig) ReadBool(key string) (bool, error) {
 	// unlike git, the mock can only store one value for the same key
 	val, ok := mc.config[key]
 	if !ok {
@@ -59,7 +61,7 @@ func (mc *memConfig) ReadBool(key string) (bool, error) {
 	return strconv.ParseBool(val)
 }
 
-func (mc *memConfig) ReadTimestamp(key string) (time.Time, error) {
+func (mc *MemConfig) ReadTimestamp(key string) (time.Time, error) {
 	value, err := mc.ReadString(key)
 	if err != nil {
 		return time.Time{}, err
@@ -74,7 +76,7 @@ func (mc *memConfig) ReadTimestamp(key string) (time.Time, error) {
 }
 
 // RmConfigs remove all key/value pair matching the key prefix
-func (mc *memConfig) RemoveAll(keyPrefix string) error {
+func (mc *MemConfig) RemoveAll(keyPrefix string) error {
 	for key := range mc.config {
 		if strings.HasPrefix(key, keyPrefix) {
 			delete(mc.config, key)

repository/mock_repo.go 🔗

@@ -12,8 +12,8 @@ var _ ClockedRepo = &mockRepoForTest{}
 
 // mockRepoForTest defines an instance of Repo that can be used for testing.
 type mockRepoForTest struct {
-	config       map[string]string
-	globalConfig map[string]string
+	config       *MemConfig
+	globalConfig *MemConfig
 	blobs        map[git.Hash][]byte
 	trees        map[git.Hash]string
 	commits      map[git.Hash]commit
@@ -29,24 +29,25 @@ type commit struct {
 
 func NewMockRepoForTest() *mockRepoForTest {
 	return &mockRepoForTest{
-		config:      make(map[string]string),
-		blobs:       make(map[git.Hash][]byte),
-		trees:       make(map[git.Hash]string),
-		commits:     make(map[git.Hash]commit),
-		refs:        make(map[string]git.Hash),
-		createClock: lamport.NewClock(),
-		editClock:   lamport.NewClock(),
+		config:       NewMemConfig(),
+		globalConfig: NewMemConfig(),
+		blobs:        make(map[git.Hash][]byte),
+		trees:        make(map[git.Hash]string),
+		commits:      make(map[git.Hash]commit),
+		refs:         make(map[string]git.Hash),
+		createClock:  lamport.NewClock(),
+		editClock:    lamport.NewClock(),
 	}
 }
 
 // LocalConfig give access to the repository scoped configuration
 func (r *mockRepoForTest) LocalConfig() Config {
-	return newMemConfig(r.config)
+	return r.config
 }
 
 // GlobalConfig give access to the git global configuration
 func (r *mockRepoForTest) GlobalConfig() Config {
-	return newMemConfig(r.globalConfig)
+	return r.globalConfig
 }
 
 // GetPath returns the path to the repo.

repository/repo.go 🔗

@@ -15,6 +15,15 @@ var (
 	ErrMultipleConfigEntry = errors.New("multiple config entry for the given key")
 )
 
+// RepoConfig access the configuration of a repository
+type RepoConfig interface {
+	// LocalConfig give access to the repository scoped configuration
+	LocalConfig() Config
+
+	// GlobalConfig give access to the git global configuration
+	GlobalConfig() Config
+}
+
 // RepoCommon represent the common function the we want all the repo to implement
 type RepoCommon interface {
 	// GetPath returns the path to the repo.
@@ -31,16 +40,11 @@ type RepoCommon interface {
 
 	// GetRemotes returns the configured remotes repositories.
 	GetRemotes() (map[string]string, error)
-
-	// LocalConfig give access to the repository scoped configuration
-	LocalConfig() Config
-
-	// GlobalConfig give access to the git global configuration
-	GlobalConfig() Config
 }
 
 // Repo represents a source code repository.
 type Repo interface {
+	RepoConfig
 	RepoCommon
 
 	// FetchRefs fetch git refs from a remote