682 lines
26 KiB
Go
682 lines
26 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type personalAuthMemoryRepository struct {
|
|
mu sync.Mutex
|
|
workspace *WorkspaceConnection
|
|
connection *UserConnection
|
|
tokens GitHubOAuthTokens
|
|
replaceErr error
|
|
invalidated bool
|
|
version int64
|
|
}
|
|
|
|
func (r *personalAuthMemoryRepository) GetWorkspaceConnection(_ context.Context, _ string) (*WorkspaceConnection, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.workspace, nil
|
|
}
|
|
|
|
func (r *personalAuthMemoryRepository) GetUserConnection(_ context.Context, _, _ string) (*UserConnection, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.connection == nil {
|
|
return nil, nil
|
|
}
|
|
copy := *r.connection
|
|
return ©, nil
|
|
}
|
|
|
|
func (r *personalAuthMemoryRepository) GetPersonalConnectionGeneration(
|
|
context.Context,
|
|
string,
|
|
string,
|
|
) (int64, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.currentGenerationLocked(), nil
|
|
}
|
|
|
|
func (r *personalAuthMemoryRepository) currentGenerationLocked() int64 {
|
|
if r.version > 0 {
|
|
return r.version
|
|
}
|
|
return personalConnectionGeneration(r.connection)
|
|
}
|
|
|
|
func (r *personalAuthMemoryRepository) GetPersonalTokens(_ context.Context, _, _ string) (GitHubOAuthTokens, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.tokens, nil
|
|
}
|
|
|
|
func (r *personalAuthMemoryRepository) ReplacePersonalConnection(
|
|
_ context.Context, connection *UserConnection, tokens GitHubOAuthTokens, expectedGeneration int64,
|
|
) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.replaceErr != nil {
|
|
return r.replaceErr
|
|
}
|
|
if r.currentGenerationLocked() != expectedGeneration {
|
|
return ErrPersonalConnectionStale
|
|
}
|
|
copy := *connection
|
|
r.connection = ©
|
|
r.tokens = tokens
|
|
r.version = connection.CredentialGeneration
|
|
return nil
|
|
}
|
|
|
|
func (r *personalAuthMemoryRepository) ReplacePersonalConnectionForFlow(
|
|
_ context.Context,
|
|
connection *UserConnection,
|
|
tokens GitHubOAuthTokens,
|
|
expectedGeneration int64,
|
|
expectedWorkspace WorkspaceConnectionExpectation,
|
|
) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if !matchesWorkspaceConnectionExpectation(r.workspace, expectedWorkspace) ||
|
|
r.currentGenerationLocked() != expectedGeneration ||
|
|
connection.CredentialGeneration != expectedGeneration+1 {
|
|
return ErrOAuthFlowStale
|
|
}
|
|
copy := *connection
|
|
r.connection = ©
|
|
r.tokens = tokens
|
|
r.version = connection.CredentialGeneration
|
|
return nil
|
|
}
|
|
|
|
func (r *personalAuthMemoryRepository) MarkPersonalConnectionInvalid(
|
|
_ context.Context, _, _ string, expectedGeneration int64, _ error,
|
|
) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if personalConnectionGeneration(r.connection) != expectedGeneration {
|
|
return ErrPersonalConnectionStale
|
|
}
|
|
r.invalidated = true
|
|
if r.connection != nil {
|
|
r.connection.Status = ConnectionStatusInvalid
|
|
r.version = r.connection.CredentialGeneration
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *personalAuthMemoryRepository) RevokePersonalConnection(_ context.Context, _, _ string) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.version = r.currentGenerationLocked() + 1
|
|
r.connection = nil
|
|
r.tokens = GitHubOAuthTokens{}
|
|
return nil
|
|
}
|
|
|
|
func (r *personalAuthMemoryRepository) RevokePersonalConnectionIfUnchanged(
|
|
_ context.Context,
|
|
expected *UserConnection,
|
|
) (bool, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if !sameUserConnectionVersion(r.connection, expected) {
|
|
return false, nil
|
|
}
|
|
r.version = r.currentGenerationLocked() + 1
|
|
r.connection = nil
|
|
r.tokens = GitHubOAuthTokens{}
|
|
return true, nil
|
|
}
|
|
|
|
func (r *personalAuthMemoryRepository) RevokeWorkspacePersonalConnections(_ context.Context, _ string) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.version = r.currentGenerationLocked() + 1
|
|
r.connection = nil
|
|
r.tokens = GitHubOAuthTokens{}
|
|
return nil
|
|
}
|
|
|
|
type personalOAuthRemote struct {
|
|
fakeGitHubAppOAuth
|
|
refreshed GitHubOAuthTokens
|
|
refreshErr error
|
|
refreshArg string
|
|
refreshes atomic.Int32
|
|
started chan struct{}
|
|
release chan struct{}
|
|
}
|
|
|
|
func (r *personalOAuthRemote) RefreshUserToken(
|
|
ctx context.Context, refreshToken string,
|
|
) (GitHubOAuthTokens, error) {
|
|
r.refreshes.Add(1)
|
|
r.refreshArg = refreshToken
|
|
if r.started != nil {
|
|
select {
|
|
case r.started <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
if r.release != nil {
|
|
select {
|
|
case <-r.release:
|
|
case <-ctx.Done():
|
|
return GitHubOAuthTokens{}, ctx.Err()
|
|
}
|
|
}
|
|
return r.refreshed, r.refreshErr
|
|
}
|
|
|
|
func TestPersonalAuthStartUsesPKCEAndWorkspaceAppBoundary(t *testing.T) {
|
|
flowStore := &oauthFlowMemoryStore{}
|
|
flows := NewOAuthFlowManager(flowStore)
|
|
flows.random = strings.NewReader(strings.Repeat("g", oauthRandomBytes*2))
|
|
repo := &personalAuthMemoryRepository{workspace: activeAppWorkspace("workspace-1", 42)}
|
|
service := NewPersonalAuthService(
|
|
PersonalAuthConfig{RegistrationID: "registration-test", ClientID: "Iv1.client", CallbackURL: "https://kandev.example/personal/callback"},
|
|
flows, repo, &personalOAuthRemote{},
|
|
)
|
|
|
|
started, err := service.Start(context.Background(), "workspace-1", "user-1")
|
|
if err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
parsed, err := url.Parse(started.URL)
|
|
if err != nil {
|
|
t.Fatalf("parse URL: %v", err)
|
|
}
|
|
query := parsed.Query()
|
|
if parsed.Path != "/login/oauth/authorize" || query.Get("code_challenge_method") != "S256" ||
|
|
query.Get("code_challenge") == "" || query.Get("state") == "" {
|
|
t.Fatalf("personal authorization URL = %q", started.URL)
|
|
}
|
|
}
|
|
|
|
func TestPersonalAuthCompleteAtomicallyStoresVerifiedIdentity(t *testing.T) {
|
|
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
|
flowStore := &oauthFlowMemoryStore{}
|
|
flows := NewOAuthFlowManager(flowStore)
|
|
flows.now = func() time.Time { return now }
|
|
flows.random = strings.NewReader(strings.Repeat("h", oauthRandomBytes*2))
|
|
repo := &personalAuthMemoryRepository{workspace: activeAppWorkspace("workspace-1", 42)}
|
|
refreshExpiry := now.Add(180 * 24 * time.Hour)
|
|
remote := &personalOAuthRemote{fakeGitHubAppOAuth: fakeGitHubAppOAuth{
|
|
tokens: GitHubOAuthTokens{
|
|
AccessToken: "access", RefreshToken: "refresh", AccessExpiresAt: now.Add(8 * time.Hour),
|
|
RefreshExpiresAt: &refreshExpiry,
|
|
},
|
|
user: GitHubOAuthUser{ID: 11, Login: "octocat"}, canAccess: true,
|
|
}}
|
|
service := NewPersonalAuthService(
|
|
PersonalAuthConfig{RegistrationID: "registration-test", ClientID: "Iv1.client", CallbackURL: "https://kandev.example/personal/callback"},
|
|
flows, repo, remote,
|
|
)
|
|
service.now = func() time.Time { return now }
|
|
started, err := service.Start(context.Background(), "workspace-1", "user-1")
|
|
if err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
_, err = service.Complete(context.Background(), PersonalAuthCallback{
|
|
State: started.State, Code: "oauth-code",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Complete() error = %v", err)
|
|
}
|
|
if repo.connection == nil || repo.connection.GitHubUserID != 11 || repo.connection.Login != "octocat" ||
|
|
repo.connection.CredentialGeneration != 1 || repo.tokens.RefreshToken != "refresh" {
|
|
t.Fatalf("stored personal connection = %+v, tokens = %+v", repo.connection, repo.tokens)
|
|
}
|
|
}
|
|
|
|
func TestPersonalAuthCompleteRejectsUserOutsideWorkspaceInstallation(t *testing.T) {
|
|
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
|
flowStore := &oauthFlowMemoryStore{}
|
|
flows := NewOAuthFlowManager(flowStore)
|
|
flows.now = func() time.Time { return now }
|
|
flows.random = strings.NewReader(strings.Repeat("i", oauthRandomBytes*2))
|
|
repo := &personalAuthMemoryRepository{workspace: activeAppWorkspace("workspace-1", 42)}
|
|
remote := &personalOAuthRemote{fakeGitHubAppOAuth: fakeGitHubAppOAuth{
|
|
tokens: GitHubOAuthTokens{AccessToken: "access", AccessExpiresAt: now.Add(time.Hour)},
|
|
user: GitHubOAuthUser{ID: 11, Login: "outsider"}, canAccess: false,
|
|
}}
|
|
service := NewPersonalAuthService(
|
|
PersonalAuthConfig{RegistrationID: "registration-test", ClientID: "Iv1.client", CallbackURL: "https://kandev.example/personal/callback"},
|
|
flows, repo, remote,
|
|
)
|
|
service.now = func() time.Time { return now }
|
|
started, err := service.Start(context.Background(), "workspace-1", "user-1")
|
|
if err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
_, err = service.Complete(context.Background(), PersonalAuthCallback{
|
|
WorkspaceID: "workspace-1", UserID: "user-1", State: started.State, Code: "oauth-code",
|
|
})
|
|
if !errors.Is(err, ErrInstallationAssociationUnverified) || repo.connection != nil {
|
|
t.Fatalf("Complete() error = %v, connection = %+v", err, repo.connection)
|
|
}
|
|
}
|
|
|
|
func TestPersonalAuthCompleteCannotSurviveWorkspaceSwitchToPAT(t *testing.T) {
|
|
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
|
flowStore := &oauthFlowMemoryStore{}
|
|
flows := NewOAuthFlowManager(flowStore)
|
|
flows.now = func() time.Time { return now }
|
|
flows.random = strings.NewReader(strings.Repeat("p", oauthRandomBytes*2))
|
|
repo := &personalAuthMemoryRepository{workspace: activeAppWorkspace("workspace-1", 42)}
|
|
remote := &personalOAuthRemote{fakeGitHubAppOAuth: fakeGitHubAppOAuth{
|
|
tokens: GitHubOAuthTokens{
|
|
AccessToken: "access", RefreshToken: "refresh", AccessExpiresAt: now.Add(time.Hour),
|
|
},
|
|
user: GitHubOAuthUser{ID: 11, Login: "octocat"}, canAccess: true,
|
|
}}
|
|
service := NewPersonalAuthService(
|
|
PersonalAuthConfig{RegistrationID: "registration-test", ClientID: "Iv1.client", CallbackURL: "https://kandev.example/personal/callback"},
|
|
flows, repo, remote,
|
|
)
|
|
service.now = func() time.Time { return now }
|
|
started, err := service.Start(context.Background(), "workspace-1", "user-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repo.workspace = &WorkspaceConnection{
|
|
WorkspaceID: "workspace-1", Source: ConnectionSourcePAT, Login: "octocat",
|
|
Status: ConnectionStatusActive, CredentialGeneration: 2,
|
|
}
|
|
|
|
_, err = service.Complete(context.Background(), PersonalAuthCallback{State: started.State, Code: "oauth-code"})
|
|
if !errors.Is(err, ErrGitHubPersonalRequired) || repo.connection != nil || repo.tokens.AccessToken != "" {
|
|
t.Fatalf("Complete() error = %v, connection = %+v, tokens = %+v", err, repo.connection, repo.tokens)
|
|
}
|
|
}
|
|
|
|
func TestPersonalAuthCompletionRacingPATSwitchLeavesNoPersonalConnection(t *testing.T) {
|
|
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
|
flowStore := &oauthFlowMemoryStore{}
|
|
flows := NewOAuthFlowManager(flowStore)
|
|
flows.now = func() time.Time { return now }
|
|
flows.random = strings.NewReader(strings.Repeat("r", oauthRandomBytes*2))
|
|
repo := &personalAuthMemoryRepository{workspace: activeAppWorkspace("workspace-1", 42)}
|
|
remote := &personalOAuthRemote{fakeGitHubAppOAuth: fakeGitHubAppOAuth{
|
|
tokens: GitHubOAuthTokens{
|
|
AccessToken: "access", RefreshToken: "refresh", AccessExpiresAt: now.Add(time.Hour),
|
|
},
|
|
user: GitHubOAuthUser{ID: 11, Login: "octocat"}, canAccess: true,
|
|
exchangeStarted: make(chan struct{}, 1), releaseExchange: make(chan struct{}),
|
|
}}
|
|
service := NewPersonalAuthService(
|
|
PersonalAuthConfig{RegistrationID: "registration-test", ClientID: "Iv1.client", CallbackURL: "https://kandev.example/personal/callback"},
|
|
flows, repo, remote,
|
|
)
|
|
service.now = func() time.Time { return now }
|
|
var workspaceLock sync.Mutex
|
|
service.SetWorkspaceMutationLock(func(string) *sync.Mutex { return &workspaceLock })
|
|
started, err := service.Start(context.Background(), "workspace-1", "user-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
completeDone := make(chan error, 1)
|
|
go func() {
|
|
_, completeErr := service.Complete(
|
|
context.Background(), PersonalAuthCallback{State: started.State, Code: "oauth-code"},
|
|
)
|
|
completeDone <- completeErr
|
|
}()
|
|
<-remote.exchangeStarted
|
|
patDone := make(chan struct{})
|
|
go func() {
|
|
workspaceLock.Lock()
|
|
repo.mu.Lock()
|
|
repo.workspace = &WorkspaceConnection{
|
|
WorkspaceID: "workspace-1", Source: ConnectionSourcePAT, Login: "octocat",
|
|
Status: ConnectionStatusActive, CredentialGeneration: 2,
|
|
}
|
|
repo.connection = nil
|
|
repo.tokens = GitHubOAuthTokens{}
|
|
repo.mu.Unlock()
|
|
workspaceLock.Unlock()
|
|
close(patDone)
|
|
}()
|
|
close(remote.releaseExchange)
|
|
if err := <-completeDone; err != nil {
|
|
t.Fatalf("Complete() error = %v", err)
|
|
}
|
|
<-patDone
|
|
if repo.workspace.Source != ConnectionSourcePAT || repo.connection != nil || repo.tokens.AccessToken != "" {
|
|
t.Fatalf("workspace=%+v connection=%+v tokens=%+v", repo.workspace, repo.connection, repo.tokens)
|
|
}
|
|
}
|
|
|
|
func TestPersonalAuthCompleteCannotOverwriteNewerReconnect(t *testing.T) {
|
|
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
|
flowStore := &oauthFlowMemoryStore{}
|
|
flows := NewOAuthFlowManager(flowStore)
|
|
flows.now = func() time.Time { return now }
|
|
flows.random = strings.NewReader(strings.Repeat("q", oauthRandomBytes*2))
|
|
repo := &personalAuthMemoryRepository{
|
|
workspace: activeAppWorkspace("workspace-1", 42),
|
|
connection: &UserConnection{
|
|
WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-test",
|
|
GitHubUserID: 11, Login: "old",
|
|
Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1,
|
|
},
|
|
}
|
|
remote := &personalOAuthRemote{fakeGitHubAppOAuth: fakeGitHubAppOAuth{
|
|
tokens: GitHubOAuthTokens{
|
|
AccessToken: "stale", RefreshToken: "stale-refresh", AccessExpiresAt: now.Add(time.Hour),
|
|
},
|
|
user: GitHubOAuthUser{ID: 11, Login: "old"}, canAccess: true,
|
|
}}
|
|
service := NewPersonalAuthService(
|
|
PersonalAuthConfig{RegistrationID: "registration-test", ClientID: "Iv1.client", CallbackURL: "https://kandev.example/personal/callback"},
|
|
flows, repo, remote,
|
|
)
|
|
service.now = func() time.Time { return now }
|
|
started, err := service.Start(context.Background(), "workspace-1", "user-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repo.RevokePersonalConnection(context.Background(), "workspace-1", "user-1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reconnected := &UserConnection{
|
|
WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-test",
|
|
GitHubUserID: 11, Login: "new",
|
|
Status: ConnectionStatusActive, AccessExpiresAt: now.Add(2 * time.Hour), CredentialGeneration: 3,
|
|
}
|
|
if err := repo.ReplacePersonalConnection(context.Background(), reconnected, GitHubOAuthTokens{
|
|
AccessToken: "new", RefreshToken: "new-refresh", AccessExpiresAt: now.Add(2 * time.Hour),
|
|
}, 2); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = service.Complete(context.Background(), PersonalAuthCallback{State: started.State, Code: "oauth-code"})
|
|
if !errors.Is(err, ErrOAuthFlowStale) || repo.connection.Login != "new" || repo.tokens.AccessToken != "new" {
|
|
t.Fatalf("Complete() error = %v, connection = %+v, tokens = %+v", err, repo.connection, repo.tokens)
|
|
}
|
|
}
|
|
|
|
func TestPersonalAuthRefreshFailureDoesNotReplaceUsableSecrets(t *testing.T) {
|
|
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
|
oldTokens := GitHubOAuthTokens{
|
|
AccessToken: "old-access", RefreshToken: "old-refresh", AccessExpiresAt: now.Add(time.Minute),
|
|
}
|
|
repo := &personalAuthMemoryRepository{
|
|
workspace: activeAppWorkspace("workspace-1", 42),
|
|
connection: &UserConnection{
|
|
WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-test",
|
|
GitHubUserID: 11, Login: "octocat",
|
|
Status: ConnectionStatusActive, AccessExpiresAt: oldTokens.AccessExpiresAt, CredentialGeneration: 4,
|
|
},
|
|
tokens: oldTokens, replaceErr: errors.New("atomic repository commit failed"),
|
|
}
|
|
remote := &personalOAuthRemote{
|
|
fakeGitHubAppOAuth: fakeGitHubAppOAuth{user: GitHubOAuthUser{ID: 11, Login: "octocat"}, canAccess: true},
|
|
refreshed: GitHubOAuthTokens{
|
|
AccessToken: "new-access", RefreshToken: "new-refresh", AccessExpiresAt: now.Add(8 * time.Hour),
|
|
},
|
|
}
|
|
service := NewPersonalAuthService(PersonalAuthConfig{RegistrationID: "registration-test"}, nil, repo, remote)
|
|
service.now = func() time.Time { return now }
|
|
|
|
_, _, err := service.ResolvePersonalToken(context.Background(), "workspace-1", "user-1")
|
|
if err == nil || repo.tokens.AccessToken != "old-access" || repo.connection.CredentialGeneration != 4 {
|
|
t.Fatalf("ResolvePersonalToken() error = %v, connection = %+v, tokens = %+v", err, repo.connection, repo.tokens)
|
|
}
|
|
}
|
|
|
|
func TestPersonalAuthRefreshRevokesEffectiveAuthWhenInstallationAccessIsLost(t *testing.T) {
|
|
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
|
repo := &personalAuthMemoryRepository{
|
|
workspace: activeAppWorkspace("workspace-1", 42),
|
|
connection: &UserConnection{
|
|
WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-test",
|
|
GitHubUserID: 11, Login: "octocat",
|
|
Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Minute), CredentialGeneration: 2,
|
|
},
|
|
tokens: GitHubOAuthTokens{AccessToken: "old", RefreshToken: "refresh", AccessExpiresAt: now.Add(time.Minute)},
|
|
}
|
|
remote := &personalOAuthRemote{
|
|
fakeGitHubAppOAuth: fakeGitHubAppOAuth{user: GitHubOAuthUser{ID: 11, Login: "octocat"}, canAccess: false},
|
|
refreshed: GitHubOAuthTokens{AccessToken: "new", RefreshToken: "new-refresh", AccessExpiresAt: now.Add(time.Hour)},
|
|
}
|
|
service := NewPersonalAuthService(PersonalAuthConfig{RegistrationID: "registration-test"}, nil, repo, remote)
|
|
service.now = func() time.Time { return now }
|
|
_, _, err := service.ResolvePersonalToken(context.Background(), "workspace-1", "user-1")
|
|
if !errors.Is(err, ErrInstallationAssociationUnverified) || !repo.invalidated || repo.tokens.AccessToken != "old" {
|
|
t.Fatalf("ResolvePersonalToken() error = %v, invalidated = %v, tokens = %+v", err, repo.invalidated, repo.tokens)
|
|
}
|
|
}
|
|
|
|
func TestPersonalAuthRefreshRejectsChangedGitHubUser(t *testing.T) {
|
|
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
|
repo := &personalAuthMemoryRepository{
|
|
workspace: activeAppWorkspace("workspace-1", 42),
|
|
connection: &UserConnection{
|
|
WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-test",
|
|
GitHubUserID: 11, Login: "octocat",
|
|
Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Minute), CredentialGeneration: 2,
|
|
},
|
|
tokens: GitHubOAuthTokens{AccessToken: "old", RefreshToken: "refresh", AccessExpiresAt: now.Add(time.Minute)},
|
|
}
|
|
remote := &personalOAuthRemote{
|
|
fakeGitHubAppOAuth: fakeGitHubAppOAuth{user: GitHubOAuthUser{ID: 99, Login: "attacker"}},
|
|
refreshed: GitHubOAuthTokens{AccessToken: "new", RefreshToken: "new-refresh", AccessExpiresAt: now.Add(time.Hour)},
|
|
}
|
|
service := NewPersonalAuthService(PersonalAuthConfig{RegistrationID: "registration-test"}, nil, repo, remote)
|
|
service.now = func() time.Time { return now }
|
|
_, _, err := service.ResolvePersonalToken(context.Background(), "workspace-1", "user-1")
|
|
if !errors.Is(err, ErrPersonalIdentityChanged) || !repo.invalidated || repo.tokens.AccessToken != "old" {
|
|
t.Fatalf("ResolvePersonalToken() error = %v, invalidated = %v, tokens = %+v", err, repo.invalidated, repo.tokens)
|
|
}
|
|
}
|
|
|
|
func TestPersonalAuthRefreshCoalescesConcurrentCallers(t *testing.T) {
|
|
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
|
repo := expiringPersonalAuthRepository(now)
|
|
remote := &personalOAuthRemote{
|
|
fakeGitHubAppOAuth: fakeGitHubAppOAuth{user: GitHubOAuthUser{ID: 11, Login: "octocat"}, canAccess: true},
|
|
refreshed: GitHubOAuthTokens{
|
|
AccessToken: "new", RefreshToken: "new-refresh", AccessExpiresAt: now.Add(time.Hour),
|
|
},
|
|
started: make(chan struct{}, 1), release: make(chan struct{}),
|
|
}
|
|
service := NewPersonalAuthService(PersonalAuthConfig{RegistrationID: "registration-test"}, nil, repo, remote)
|
|
service.now = func() time.Time { return now }
|
|
|
|
type result struct {
|
|
token string
|
|
err error
|
|
}
|
|
results := make(chan result, 2)
|
|
resolve := func() {
|
|
token, _, err := service.ResolvePersonalToken(context.Background(), "workspace-1", "user-1")
|
|
results <- result{token: token, err: err}
|
|
}
|
|
go resolve()
|
|
<-remote.started
|
|
go resolve()
|
|
time.Sleep(20 * time.Millisecond)
|
|
close(remote.release)
|
|
|
|
for range 2 {
|
|
got := <-results
|
|
if got.err != nil || got.token != "new" {
|
|
t.Fatalf("ResolvePersonalToken() = %q, %v", got.token, got.err)
|
|
}
|
|
}
|
|
if got := remote.refreshes.Load(); got != 1 {
|
|
t.Fatalf("RefreshUserToken calls = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestPersonalAuthRefreshCallerCancellationDoesNotCancelSharedRotation(t *testing.T) {
|
|
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
|
repo := expiringPersonalAuthRepository(now)
|
|
remote := &personalOAuthRemote{
|
|
fakeGitHubAppOAuth: fakeGitHubAppOAuth{user: GitHubOAuthUser{ID: 11, Login: "octocat"}, canAccess: true},
|
|
refreshed: GitHubOAuthTokens{
|
|
AccessToken: "new", RefreshToken: "new-refresh", AccessExpiresAt: now.Add(time.Hour),
|
|
},
|
|
started: make(chan struct{}, 1), release: make(chan struct{}),
|
|
}
|
|
service := NewPersonalAuthService(PersonalAuthConfig{RegistrationID: "registration-test"}, nil, repo, remote)
|
|
service.now = func() time.Time { return now }
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, _, err := service.ResolvePersonalToken(ctx, "workspace-1", "user-1")
|
|
done <- err
|
|
}()
|
|
<-remote.started
|
|
cancel()
|
|
if err := <-done; !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("canceled caller error = %v", err)
|
|
}
|
|
close(remote.release)
|
|
token, _, err := service.ResolvePersonalToken(context.Background(), "workspace-1", "user-1")
|
|
if err != nil || token != "new" {
|
|
t.Fatalf("ResolvePersonalToken() after canceled caller = %q, %v", token, err)
|
|
}
|
|
}
|
|
|
|
func TestPersonalAuthTransientRefreshFailureDoesNotInvalidateConnection(t *testing.T) {
|
|
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
|
repo := expiringPersonalAuthRepository(now)
|
|
remote := &personalOAuthRemote{refreshErr: errors.New("temporary network failure")}
|
|
service := NewPersonalAuthService(PersonalAuthConfig{RegistrationID: "registration-test"}, nil, repo, remote)
|
|
service.now = func() time.Time { return now }
|
|
|
|
if _, _, err := service.ResolvePersonalToken(context.Background(), "workspace-1", "user-1"); err == nil {
|
|
t.Fatal("expected refresh failure")
|
|
}
|
|
if repo.invalidated || repo.connection.Status != ConnectionStatusActive {
|
|
t.Fatalf("transient failure invalidated connection: %+v", repo.connection)
|
|
}
|
|
}
|
|
|
|
func TestPersonalAuthInvalidGrantRefreshFailureInvalidatesConnection(t *testing.T) {
|
|
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
|
repo := expiringPersonalAuthRepository(now)
|
|
remote := &personalOAuthRemote{refreshErr: &GitHubOAuthError{Code: "invalid_grant"}}
|
|
service := NewPersonalAuthService(PersonalAuthConfig{RegistrationID: "registration-test"}, nil, repo, remote)
|
|
service.now = func() time.Time { return now }
|
|
|
|
if _, _, err := service.ResolvePersonalToken(context.Background(), "workspace-1", "user-1"); err == nil {
|
|
t.Fatal("expected refresh failure")
|
|
}
|
|
if !repo.invalidated || repo.connection.Status != ConnectionStatusInvalid {
|
|
t.Fatalf("invalid grant did not invalidate connection: %+v", repo.connection)
|
|
}
|
|
}
|
|
|
|
func TestPersonalAuthRevokeWinsRaceWithRefresh(t *testing.T) {
|
|
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
|
repo := expiringPersonalAuthRepository(now)
|
|
remote := &personalOAuthRemote{
|
|
fakeGitHubAppOAuth: fakeGitHubAppOAuth{user: GitHubOAuthUser{ID: 11, Login: "octocat"}, canAccess: true},
|
|
refreshed: GitHubOAuthTokens{
|
|
AccessToken: "new", RefreshToken: "new-refresh", AccessExpiresAt: now.Add(time.Hour),
|
|
},
|
|
started: make(chan struct{}, 1), release: make(chan struct{}),
|
|
}
|
|
service := NewPersonalAuthService(PersonalAuthConfig{RegistrationID: "registration-test"}, nil, repo, remote)
|
|
service.now = func() time.Time { return now }
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, _, err := service.ResolvePersonalToken(context.Background(), "workspace-1", "user-1")
|
|
done <- err
|
|
}()
|
|
<-remote.started
|
|
if err := service.Revoke(context.Background(), "workspace-1", "user-1"); err != nil {
|
|
t.Fatalf("Revoke() error = %v", err)
|
|
}
|
|
close(remote.release)
|
|
if err := <-done; !errors.Is(err, ErrPersonalConnectionStale) {
|
|
t.Fatalf("ResolvePersonalToken() error = %v, want stale connection", err)
|
|
}
|
|
repo.mu.Lock()
|
|
defer repo.mu.Unlock()
|
|
if repo.connection != nil || repo.tokens.AccessToken != "" || repo.tokens.RefreshToken != "" {
|
|
t.Fatalf("revoked connection was resurrected: connection=%+v tokens=%+v", repo.connection, repo.tokens)
|
|
}
|
|
}
|
|
|
|
func TestPersonalAuthorizationLateRevokeDoesNotDeleteReconnect(t *testing.T) {
|
|
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
|
current := &UserConnection{
|
|
WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-test",
|
|
GitHubUserID: 11, Login: "octocat",
|
|
Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Hour),
|
|
CredentialGeneration: 2, UpdatedAt: now.Add(time.Second),
|
|
}
|
|
repo := &personalAuthMemoryRepository{
|
|
workspace: activeAppWorkspace("workspace-1", 42), connection: current,
|
|
tokens: GitHubOAuthTokens{
|
|
AccessToken: "reconnected", RefreshToken: "refresh", AccessExpiresAt: now.Add(time.Hour),
|
|
},
|
|
}
|
|
service := NewPersonalAuthService(
|
|
PersonalAuthConfig{RegistrationID: "registration-test"}, nil, repo,
|
|
&personalOAuthRemote{fakeGitHubAppOAuth: fakeGitHubAppOAuth{
|
|
user: GitHubOAuthUser{ID: 11, Login: "octocat"},
|
|
}},
|
|
)
|
|
|
|
revoked, err := service.ReconcileAuthorizationRevocation(context.Background(), current)
|
|
if err != nil || revoked || repo.connection == nil || repo.tokens.AccessToken != "reconnected" {
|
|
t.Fatalf("ReconcileAuthorizationRevocation() = %v, %v; connection=%+v tokens=%+v",
|
|
revoked, err, repo.connection, repo.tokens)
|
|
}
|
|
}
|
|
|
|
func TestPersonalConnectionVersionIncludesAppRegistration(t *testing.T) {
|
|
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
|
expected := &UserConnection{
|
|
WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-work",
|
|
GitHubUserID: 11, CredentialGeneration: 2, UpdatedAt: now,
|
|
}
|
|
current := *expected
|
|
current.AppRegistrationID = "registration-personal"
|
|
|
|
if sameUserConnectionVersion(¤t, expected) {
|
|
t.Fatal("connections from different App registrations matched")
|
|
}
|
|
}
|
|
|
|
func expiringPersonalAuthRepository(now time.Time) *personalAuthMemoryRepository {
|
|
return &personalAuthMemoryRepository{
|
|
workspace: activeAppWorkspace("workspace-1", 42),
|
|
connection: &UserConnection{
|
|
WorkspaceID: "workspace-1", UserID: "user-1", AppRegistrationID: "registration-test",
|
|
GitHubUserID: 11, Login: "octocat",
|
|
Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Minute), CredentialGeneration: 2,
|
|
},
|
|
tokens: GitHubOAuthTokens{
|
|
AccessToken: "old", RefreshToken: "refresh", AccessExpiresAt: now.Add(time.Minute),
|
|
},
|
|
}
|
|
}
|
|
|
|
func activeAppWorkspace(workspaceID string, installationID int64) *WorkspaceConnection {
|
|
return &WorkspaceConnection{
|
|
WorkspaceID: workspaceID, Source: ConnectionSourceGitHubAppInstallation,
|
|
GitHubHost: defaultGitHubHost, InstallationID: &installationID,
|
|
AppRegistrationID: "registration-test",
|
|
InstallationAccountLogin: "acme", InstallationAccountType: "Organization",
|
|
Status: ConnectionStatusActive, CredentialGeneration: 1,
|
|
}
|
|
}
|