mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(coderd/externalauth): detect concurrent refresh race to prevent cache poisoning (#24228)
<!-- If you have used AI to produce some or all of this PR, please ensure you have read our [AI Contribution guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING) before submitting. --> Fixes https://github.com/coder/coder/issues/17069 Builds on #24332 and #24334 which addressed token persistence and rate limit handling. ## Problem When multiple concurrent requests race to refresh an expiring external auth token, providers with single-use refresh tokens (e.g., GitHub Apps) reject all but the first refresh attempt with `bad_refresh_token`. The losing request caches this transient error in the `oauth_refresh_failure_reason` database column and clears the refresh token, blocking all subsequent refresh attempts until the user manually re-authenticates. This is common for users with multiple terminals, IDE connections, or workspaces open, all of which poll the external auth endpoint and trigger concurrent refreshes when the token nears expiry. Database analysis showed 5 of 7 affected users failed within 5-10 seconds of token expiry, matching the Go oauth2 library's `expiryDelta` window. ## Fix Before caching a `bad_refresh_token` failure, re-read the external auth link from the database. If the refresh token has changed (indicating a concurrent caller already refreshed successfully), return the winner's updated link instead of writing a failure. An empty-string guard ensures a token cleared by another loser isn't mistaken for a winner's successful refresh. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Garrett Delfosse <garrett@coder.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
Garrett Delfosse
parent
1ecdad689b
commit
da6e708bd2
@@ -200,6 +200,24 @@ 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.
|
||||
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
|
||||
}
|
||||
|
||||
reason := err.Error()
|
||||
if len(reason) > failureReasonLimit {
|
||||
// Limit the length of the error message to prevent
|
||||
|
||||
@@ -203,7 +203,9 @@ func TestRefreshToken(t *testing.T) {
|
||||
}
|
||||
|
||||
// Try again with a bad refresh token error. This will invalidate the
|
||||
// refresh token, and not retry again. Expect DB call to remove the refresh token
|
||||
// refresh token, and not retry again. Expect DB calls to check for
|
||||
// concurrent refresh (GetExternalAuthLink) and then remove the refresh token.
|
||||
mDB.EXPECT().GetExternalAuthLink(gomock.Any(), gomock.Any()).Return(link, nil).Times(1)
|
||||
mDB.EXPECT().UpdateExternalAuthLinkRefreshToken(gomock.Any(), gomock.Any()).Return(nil).Times(1)
|
||||
refreshErr = &oauth2.RetrieveError{ // github error
|
||||
Response: &http.Response{
|
||||
@@ -225,6 +227,57 @@ func TestRefreshToken(t *testing.T) {
|
||||
require.Equal(t, refreshCount, totalRefreshes)
|
||||
})
|
||||
|
||||
// ConcurrentRefreshRace tests that when multiple concurrent requests
|
||||
// race to refresh the same token, the loser does not poison the
|
||||
// database with a cached "bad_refresh_token" failure. This
|
||||
// reproduces the issue described in coder/coder#17069 where
|
||||
// providers with single-use refresh tokens (e.g., GitHub Apps)
|
||||
// reject the second refresh attempt, and the resulting error was
|
||||
// incorrectly cached.
|
||||
t.Run("ConcurrentRefreshRace", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
mDB := dbmock.NewMockStore(ctrl)
|
||||
|
||||
fake, config, link := setupOauth2Test(t, testConfig{
|
||||
FakeIDPOpts: []oidctest.FakeIDPOpt{
|
||||
oidctest.WithRefresh(func(_ string) error {
|
||||
return &oauth2.RetrieveError{
|
||||
Response: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
},
|
||||
ErrorCode: "bad_refresh_token",
|
||||
}
|
||||
}),
|
||||
},
|
||||
ExternalAuthOpt: func(cfg *externalauth.Config) {},
|
||||
})
|
||||
|
||||
ctx := oidc.ClientContext(context.Background(), fake.HTTPClient(nil))
|
||||
link.OAuthExpiry = time.Now().Add(time.Hour * -1)
|
||||
|
||||
// Simulate a concurrent winner: when the loser re-reads the
|
||||
// DB, the refresh token has changed (the winner stored a new
|
||||
// one). The loser should return the updated link instead of
|
||||
// caching the failure.
|
||||
winnerLink := link
|
||||
winnerLink.OAuthRefreshToken = "winner-refresh-token"
|
||||
winnerLink.OAuthAccessToken = "winner-access-token"
|
||||
mDB.EXPECT().GetExternalAuthLink(gomock.Any(), database.GetExternalAuthLinkParams{
|
||||
ProviderID: link.ProviderID,
|
||||
UserID: link.UserID,
|
||||
}).Return(winnerLink, nil).Times(1)
|
||||
|
||||
// UpdateExternalAuthLinkRefreshToken should NOT be called
|
||||
// because the re-read detected the concurrent refresh.
|
||||
|
||||
result, err := config.RefreshToken(ctx, mDB, link)
|
||||
require.NoError(t, err, "loser should succeed using the winner's token")
|
||||
require.Equal(t, "winner-access-token", result.OAuthAccessToken)
|
||||
require.Equal(t, "winner-refresh-token", result.OAuthRefreshToken)
|
||||
})
|
||||
|
||||
// ValidateFailure tests if the token is no longer valid with a 401 response.
|
||||
t.Run("ValidateFailure", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
Reference in New Issue
Block a user