Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions bridge/core/auth/credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"strconv"
"net/url"
"slices"
"strings"
"time"

Expand Down Expand Up @@ -173,6 +174,18 @@ func List(repo repository.RepoKeyring, opts ...ListOption) ([]Credential, error)
}
}

// The keyring returns keys in an arbitrary (backend dependent) order.
// Callers commonly pick the first matching credential, so make that
// choice deterministic and sensible: the most recently created one first.
// This way, adding a fresh credential supersedes older (possibly revoked)
// ones instead of being shadowed by them.
slices.SortStableFunc(credentials, func(a, b Credential) int {
if c := b.CreateTime().Compare(a.CreateTime()); c != 0 {
return c
}
return strings.Compare(a.ID().String(), b.ID().String())
})
Comment thread
greptile-apps[bot] marked this conversation as resolved.

return credentials, nil
}

Expand All @@ -198,7 +211,7 @@ func Store(repo repository.RepoKeyring, cred Credential) error {

confs[keyringKeyKind] = string(cred.Kind())
confs[keyringKeyTarget] = cred.Target()
confs[keyringKeyCreateTime] = strconv.Itoa(int(cred.CreateTime().Unix()))
confs[keyringKeyCreateTime] = cred.CreateTime().UTC().Format(time.RFC3339Nano)
confs[keyringKeySalt] = base64.StdEncoding.EncodeToString(cred.Salt())

for key, val := range cred.Metadata() {
Expand Down Expand Up @@ -238,3 +251,30 @@ func (b ById) Less(i, j int) bool {
func (b ById) Swap(i, j int) {
b[i], b[j] = b[j], b[i]
}

// NormalizeBaseURL canonicalizes a base URL by lowercasing the scheme and host,
// removing trailing slashes.
func NormalizeBaseURL(rawURL string) string {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return ""
}
u, err := url.Parse(rawURL)
if err != nil || u.Host == "" {
return strings.TrimRight(rawURL, "/")
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
u.Scheme = strings.ToLower(u.Scheme)
u.Host = strings.ToLower(u.Host)
u.Path = strings.TrimRight(u.Path, "/")
u.RawPath = strings.TrimRight(u.RawPath, "/")
return u.String()
}

// ValidateBaseURL checks a supplied instance URL before storing credentials.
func ValidateBaseURL(rawURL string) error {
u, err := url.Parse(rawURL)
if err != nil || u.Hostname() == "" || (u.Scheme != "https" && u.Scheme != "http") || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return fmt.Errorf("base URL must be an absolute HTTP(S) URL without credentials, query, or fragment")
}
return nil
}
130 changes: 130 additions & 0 deletions bridge/core/auth/credential_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package auth

import (
"encoding/base64"
"encoding/json"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -122,3 +125,130 @@ func testCredentialSerial(t *testing.T, original Credential) Credential {

return loaded
}

func TestListNewestFirst(t *testing.T) {
repo := repository.NewMockRepo()

now := time.Now()
var stored []*Token
// store out of chronological order on purpose
for _, age := range []time.Duration{2 * time.Hour, 0, 3 * time.Hour, time.Hour} {
token := NewToken("gitea", "value")
token.createTime = now.Add(-age)
require.NoError(t, Store(repo, token))
stored = append(stored, token)
}

creds, err := List(repo, WithTarget("gitea"))
require.NoError(t, err)
require.Len(t, creds, 4)

// expected order: age 0, 1h, 2h, 3h
expected := []entity.Id{stored[1].ID(), stored[3].ID(), stored[0].ID(), stored[2].ID()}
for i, cred := range creds {
assert.Equal(t, expected[i], cred.ID(), "position %d", i)
}
}

func TestListNewestFirstSameSecond(t *testing.T) {
repo := repository.NewMockRepo()

now := time.Now().Truncate(time.Second)
var stored []*Token
// store out of chronological order within the same second
for _, subSec := range []time.Duration{500 * time.Millisecond, 0, 750 * time.Millisecond, 250 * time.Millisecond} {
token := NewToken("gitea", "value")
token.createTime = now.Add(subSec)
require.NoError(t, Store(repo, token))
stored = append(stored, token)
}

creds, err := List(repo, WithTarget("gitea"))
require.NoError(t, err)
require.Len(t, creds, 4)

// expected order: 750ms, 500ms, 250ms, 0ms
expected := []entity.Id{stored[2].ID(), stored[0].ID(), stored[3].ID(), stored[1].ID()}
for i, cred := range creds {
assert.Equal(t, expected[i], cred.ID(), "position %d", i)
}
}

func TestListLegacyTimestamp(t *testing.T) {
repo := repository.NewMockRepo()

tokenOld := NewToken("gitea", "old-value")
rawItem := map[string]string{
keyringKeyKind: string(tokenOld.Kind()),
keyringKeyTarget: tokenOld.Target(),
keyringKeyCreateTime: "1700000000",
keyringKeySalt: base64.StdEncoding.EncodeToString(tokenOld.Salt()),
keyringKeyTokenValue: tokenOld.Value,
}
data, err := json.Marshal(rawItem)
require.NoError(t, err)
require.NoError(t, repo.Keyring().Set(repository.Item{
Key: keyringKeyPrefix + tokenOld.ID().String(),
Data: data,
}))

tokenNew := NewToken("gitea", "new-value")
tokenNew.createTime = time.Unix(1700001000, 500000000)
require.NoError(t, Store(repo, tokenNew))

creds, err := List(repo, WithTarget("gitea"))
require.NoError(t, err)
require.Len(t, creds, 2)
assert.Equal(t, tokenNew.ID(), creds[0].ID())
assert.Equal(t, tokenOld.ID(), creds[1].ID())
}

func TestListBaseURLNormalization(t *testing.T) {
repo := repository.NewMockRepo()

tokenWithoutSlash := NewToken("gitlab", "value-1")
tokenWithoutSlash.SetMetadata(MetaKeyBaseURL, "https://gitlab.com")
require.NoError(t, Store(repo, tokenWithoutSlash))

tokenWithSlash := NewToken("gitlab", "value-2")
tokenWithSlash.SetMetadata(MetaKeyBaseURL, "https://gitlab.example.com/")
require.NoError(t, Store(repo, tokenWithSlash))

// Query with trailing slash against token without trailing slash
creds, err := List(repo, WithTarget("gitlab"), WithMeta(MetaKeyBaseURL, "https://gitlab.com/"))
require.NoError(t, err)
require.Len(t, creds, 1)
assert.Equal(t, tokenWithoutSlash.ID(), creds[0].ID())

// Query without trailing slash against token with trailing slash
creds, err = List(repo, WithTarget("gitlab"), WithMeta(MetaKeyBaseURL, "https://gitlab.example.com"))
require.NoError(t, err)
require.Len(t, creds, 1)
assert.Equal(t, tokenWithSlash.ID(), creds[0].ID())
}

func TestNormalizeBaseURL(t *testing.T) {
tests := []struct {
input string
want string
}{
{"", ""},
{" ", ""},
{"https://gitlab.com", "https://gitlab.com"},
{"https://gitlab.com/", "https://gitlab.com"},
{"https://gitlab.com///", "https://gitlab.com"},
{"HTTPS://GITLAB.COM/", "https://gitlab.com"},
{"https://gitlab.example.com/sub/group/", "https://gitlab.example.com/sub/group"},
{"https://gitlab.example.com/sub/group", "https://gitlab.example.com/sub/group"},
{"http://jira.local:8080/jira/", "http://jira.local:8080/jira"},
{"http://jira.local:8080/jira", "http://jira.local:8080/jira"},
{"jira.example.com/", "jira.example.com"},
{"jira.example.com", "jira.example.com"},
}

for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
assert.Equal(t, tc.want, NormalizeBaseURL(tc.input))
})
}
}
10 changes: 9 additions & 1 deletion bridge/core/auth/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,15 @@ func (opts *listOptions) Match(cred Credential) bool {
}

for key, val := range opts.meta {
if v, ok := cred.GetMetadata(key); !ok || v != val {
v, ok := cred.GetMetadata(key)
if !ok {
return false
}
if key == MetaKeyBaseURL {
if NormalizeBaseURL(v) != NormalizeBaseURL(val) {
return false
}
} else if v != val {
return false
}
}
Expand Down
36 changes: 27 additions & 9 deletions bridge/core/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,42 @@ import (
)

func FinishConfig(repo *cache.RepoCache, metaKey string, login string) error {
// if no user exist with the given login metadata
_, err := repo.Identities().ResolveIdentityImmutableMetadata(metaKey, login)
// Check the configured user identity first so dangling identities are handled.
user, err := repo.GetUserIdentity()
danglingUser := false
switch {
case err == nil, err == identity.ErrNoIdentitySet:
case entity.IsErrNotFound(err):
// The configured user identity points to an identity that doesn't
// exist (anymore) in this repository. Don't fail the whole bridge
// configuration for that, just create or adopt a valid identity below,
// which will replace the dangling one as the current user.
fmt.Printf("The configured user identity doesn't exist in this repository, ignoring it\n")
danglingUser = true
default:
// real error
return err
}

// if a user exists with the given login metadata
existing, err := repo.Identities().ResolveIdentityImmutableMetadata(metaKey, login)
if err != nil && !entity.IsErrNotFound(err) {
// real error
return err
}
if err == nil {
// found an already valid user, all good
// found an already valid user
if danglingUser {
if err := repo.SetUserIdentity(existing); err != nil {
return err
}
fmt.Printf("Identity %v set as current\n", existing.Id().Human())
}
return nil
}

// if a default user exist, tag it with the login
user, err := repo.GetUserIdentity()
if err != nil && err != identity.ErrNoIdentitySet {
// real error
return err
}
if err == nil {
if user != nil {
fmt.Printf("Current identity %v tagged with login %v\n", user.Id().Human(), login)
// found one
user.SetMetadata(metaKey, login)
Expand Down
73 changes: 73 additions & 0 deletions bridge/core/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package core

import (
"strings"
"testing"

"github.com/stretchr/testify/require"

"github.com/git-bug/git-bug/cache"
"github.com/git-bug/git-bug/entities/identity"
"github.com/git-bug/git-bug/entity"
"github.com/git-bug/git-bug/repository"
)

func TestFinishConfigDanglingUserIdentity(t *testing.T) {
repo := repository.CreateGoGitTestRepo(t, false)

// Point the user identity to an identity that doesn't exist in the repo,
// as happens when the identity has been removed or the config copied over.
dangling := entity.Id(strings.Repeat("a", 64))
require.NoError(t, repo.LocalConfig().StoreString("git-bug.identity", dangling.String()))

backend, err := cache.NewRepoCacheNoEvents(repo)
require.NoError(t, err)
defer backend.Close()

const metaKey = "test-login"

err = FinishConfig(backend, metaKey, "jdoe")
require.NoError(t, err)

// a new identity tagged with the login must be the current user now
user, err := backend.GetUserIdentity()
require.NoError(t, err)
require.NotEqual(t, dangling, user.Id())
require.Equal(t, "jdoe", user.ImmutableMetadata()[metaKey])

id, err := identity.GetUserIdentityId(repo)
require.NoError(t, err)
require.Equal(t, user.Id(), id)
}

func TestFinishConfigDanglingUserIdentityWithExisting(t *testing.T) {
repo := repository.CreateGoGitTestRepo(t, false)

backend, err := cache.NewRepoCacheNoEvents(repo)
require.NoError(t, err)
defer backend.Close()

const metaKey = "test-login"

// Create an identity tagged with the login metadata in the repository
existing, err := backend.Identities().NewFromGitUserRaw(map[string]string{
metaKey: "jdoe",
})
require.NoError(t, err)

// Set a dangling user identity in git config
dangling := entity.Id(strings.Repeat("a", 64))
require.NoError(t, repo.LocalConfig().StoreString("git-bug.identity", dangling.String()))

err = FinishConfig(backend, metaKey, "jdoe")
require.NoError(t, err)

// the existing identity must have replaced the dangling one as the current user
user, err := backend.GetUserIdentity()
require.NoError(t, err)
require.Equal(t, existing.Id(), user.Id())

id, err := identity.GetUserIdentityId(repo)
require.NoError(t, err)
require.Equal(t, existing.Id(), id)
}
9 changes: 7 additions & 2 deletions bridge/github/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,18 @@ func (ge *githubExporter) cacheAllClient(repo *cache.RepoCache) error {
continue
}

client := buildClient(creds[0].(*auth.Token))
token, ok := cred.(*auth.Token)
if !ok {
continue
}

client := buildClient(token)
ge.identityClient[user.Id()] = client

// assign the default client and token as well
if ge.defaultClient == nil && login == ge.conf[confKeyDefaultLogin] {
ge.defaultClient = client
ge.defaultToken = creds[0].(*auth.Token)
ge.defaultToken = token
}
}

Expand Down
Loading
Loading