Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
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
2 changes: 2 additions & 0 deletions cli/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sync/singleflight"

"github.com/coder/coder/v2/cli"
"github.com/coder/coder/v2/cli/clitest"
Expand Down Expand Up @@ -2117,6 +2118,7 @@ func TestCreateWithGitAuth(t *testing.T) {
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
DisplayName: "GitHub",
RefreshGroup: new(singleflight.Group),
}},
IncludeProvisionerDaemon: true,
})
Expand Down
5 changes: 5 additions & 0 deletions coderd/aitasks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sync/singleflight"
"golang.org/x/xerrors"

agentapisdk "github.com/coder/agentapi-sdk-go"
Expand Down Expand Up @@ -1524,6 +1525,7 @@ func TestCreateTaskExternalAuth(t *testing.T) {
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
DisplayName: "GitHub",
RefreshGroup: new(singleflight.Group),
}},
})
first := coderdtest.CreateFirstUser(t, client)
Expand Down Expand Up @@ -1582,6 +1584,7 @@ func TestCreateTaskExternalAuth(t *testing.T) {
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
DisplayName: "GitHub",
RefreshGroup: new(singleflight.Group),
}},
})
first := coderdtest.CreateFirstUser(t, client)
Expand Down Expand Up @@ -1633,6 +1636,7 @@ func TestCreateTaskExternalAuth(t *testing.T) {
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
DisplayName: "GitHub",
RefreshGroup: new(singleflight.Group),
}},
})
first := coderdtest.CreateFirstUser(t, client)
Expand Down Expand Up @@ -1665,6 +1669,7 @@ func TestCreateTaskExternalAuth(t *testing.T) {
Regex: regexp.MustCompile(`github\.com`),
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
DisplayName: "GitHub",
RefreshGroup: new(singleflight.Group),
}},
})
first := coderdtest.CreateFirstUser(t, client)
Expand Down
2 changes: 2 additions & 0 deletions coderd/coderdtest/oidctest/idp.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"golang.org/x/sync/singleflight"
"golang.org/x/xerrors"

"cdr.dev/slog/v3"
Expand Down Expand Up @@ -1641,6 +1642,7 @@ func (f *FakeIDP) ExternalAuthConfig(t testing.TB, id string, custom *ExternalAu
Scopes: []string{},
CodeURL: f.locked.Provider().DeviceCodeURL,
},
RefreshGroup: new(singleflight.Group),
}

if !custom.UseDeviceAuth {
Expand Down
66 changes: 49 additions & 17 deletions coderd/externalauth/externalauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/sqlc-dev/pqtype"
"golang.org/x/oauth2"
xgithub "golang.org/x/oauth2/github"
"golang.org/x/sync/singleflight"
"golang.org/x/xerrors"

"github.com/coder/coder/v2/coderd/database"
Expand Down Expand Up @@ -53,6 +54,12 @@ const (
defaultRefreshRetryTimeout = 10 * time.Second
)

// SingleflightGroup exposes a subset of singleflight.Group for easier testing.
// singleflight.Group should be used instead of implementing this in production.
type SingleflightGroup interface {
DoChan(key string, fn func() (any, error)) <-chan singleflight.Result
}

// Config is used for authentication for Git operations.
type Config struct {
promoauth.InstrumentedOAuth2Config
Expand Down Expand Up @@ -142,6 +149,9 @@ type Config struct {
// defaultRefreshRetryTimeout. A negative value disables transient-failure
// retries entirely, so exactly one refresh attempt is made.
RefreshRetryTimeout time.Duration

// RefreshGroup deduplicates concurrent requests.
RefreshGroup SingleflightGroup
}

// Git returns a Provider for this config if the provider type is a
Expand Down Expand Up @@ -190,6 +200,37 @@ func IsInvalidTokenError(err error) bool {

// RefreshToken automatically refreshes the token if expired and permitted.
func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAuthLink database.ExternalAuthLink) (database.ExternalAuthLink, error) {
// Prevent parallel refreshes by waiting for the result of any already
// in-flight refresh. Otherwise, the parallel calls will fail with a bad
// refresh token error as they can only be used once.
key := c.ID + ":" + externalAuthLink.UserID.String()
ch := c.RefreshGroup.DoChan(key, func() (any, error) {
// Use a detached context so if a request is canceled or times out it does
// not cancel all the other requests as well. The deadline is arbitrary but
// we give at least enough time for the refresh timeout then another 10
// seconds for updating the database and validating the link.
timeout := 10 * time.Second
if c.RefreshRetryTimeout > 0 {
timeout += c.RefreshRetryTimeout
}
rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
defer cancel()
return c.innerRefreshToken(rctx, db, externalAuthLink)
})
select {
case results := <-ch:
if newlink, ok := results.Val.(database.ExternalAuthLink); ok {
return newlink, results.Err
} else if results.Err == nil {
return externalAuthLink, xerrors.Errorf("got invalid type from token refresh: %T", results.Val)
}
return externalAuthLink, results.Err
case <-ctx.Done():
return externalAuthLink, ctx.Err()
}
}

func (c *Config) innerRefreshToken(ctx context.Context, db database.Store, externalAuthLink database.ExternalAuthLink) (database.ExternalAuthLink, error) {
// If the token is expired and refresh is disabled, we prompt
// the user to authenticate again.
if c.NoRefresh &&
Expand Down Expand Up @@ -237,21 +278,17 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu
//
// The error message is saved for debugging purposes.
if isFailedRefresh(existingToken, err) {
// Before caching the failure, re-read the external auth link
// from the database. A concurrent request may have already
// refreshed the token successfully, consuming the single-use
// refresh token (e.g., GitHub App tokens). In that case our
// "bad_refresh_token" error is a false positive from losing
// the race, and we should use the winner's updated token
// instead of poisoning the database with a cached failure.
// Before caching the failure, re-read the external auth link from the
// database. A nearly-concurrent request may have already refreshed the
// token successfully, consuming the single-use refresh token (e.g.,
// GitHub App tokens). In that case our "bad_refresh_token" error is a
// false positive from losing the race, and we should use the winner's
// updated token instead of poisoning the database with a cached failure.
currentLink, readErr := db.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{
ProviderID: externalAuthLink.ProviderID,
UserID: externalAuthLink.UserID,
})
if readErr == nil && currentLink.OAuthRefreshToken != externalAuthLink.OAuthRefreshToken {
// Another caller won the refresh race and stored a new
// refresh token. Return their updated link instead of
// caching a failure.
return currentLink, nil
}

Expand Down Expand Up @@ -322,15 +359,9 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu
// validation endpoint was unavailable (e.g. rate-limited 403), the
// new token would be silently lost and the user would be forced to
// re-authenticate manually.
// Use a detached context for the DB write only. The IDP already
// consumed the old refresh token, so if the caller's request
// context is canceled mid-save, the new token would be lost.
persistCtx, persistCancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer persistCancel()

originalAccessToken := externalAuthLink.OAuthAccessToken
if token.AccessToken != originalAccessToken {
updatedAuthLink, err := db.UpdateExternalAuthLink(persistCtx, database.UpdateExternalAuthLinkParams{
updatedAuthLink, err := db.UpdateExternalAuthLink(ctx, database.UpdateExternalAuthLinkParams{
ProviderID: c.ID,
UserID: externalAuthLink.UserID,
UpdatedAt: dbtime.Now(),
Expand Down Expand Up @@ -924,6 +955,7 @@ func ConvertConfig(instrument *promoauth.Factory, entries []codersdk.ExternalAut
MCPToolAllowRegex: mcpToolAllow,
MCPToolDenyRegex: mcpToolDeny,
CodeChallengeMethodsSupported: slice.StringEnums[promoauth.Oauth2PKCEChallengeMethod](entry.CodeChallengeMethodsSupported),
RefreshGroup: new(singleflight.Group),
}

if entry.DeviceFlow {
Expand Down
Loading
Loading