Skip to content

Commit 4f8075b

Browse files
committed
feat: keep original token refresh error in external auth
External auth refresh errors lose the original error thrown on the first refresh. This PR saves that error to the database to be raised on subsequent refresh attempts
1 parent ea7025b commit 4f8075b

File tree

9 files changed

+95
-31
lines changed

9 files changed

+95
-31
lines changed

coderd/database/dump.sql

Lines changed: 4 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
ALTER TABLE external_auth_links
2+
DROP COLUMN oauth_refresh_failure_reason
3+
;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
ALTER TABLE external_auth_links
2+
ADD COLUMN oauth_refresh_failure_reason TEXT NOT NULL DEFAULT ''
3+
;
4+
5+
COMMENT ON COLUMN external_auth_links.oauth_refresh_failure_reason IS
6+
'This error means the refresh token is invalid. Cached so we can avoid calling the external provider again for the same error.'
7+
;

coderd/database/models.go

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries.sql.go

Lines changed: 24 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/externalauth.sql

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,16 @@ UPDATE external_auth_links SET
4040
oauth_refresh_token = $6,
4141
oauth_refresh_token_key_id = $7,
4242
oauth_expiry = $8,
43-
oauth_extra = $9
43+
oauth_extra = $9,
44+
-- Only 'UpdateExternalAuthLinkRefreshToken' supports updating the oauth_refresh_failure_reason
45+
oauth_refresh_failure_reason = ''
4446
WHERE provider_id = $1 AND user_id = $2 RETURNING *;
4547

4648
-- name: UpdateExternalAuthLinkRefreshToken :exec
4749
UPDATE
4850
external_auth_links
4951
SET
52+
oauth_refresh_failure_reason = @oauth_refresh_failure_reason,
5053
oauth_refresh_token = @oauth_refresh_token,
5154
updated_at = @updated_at
5255
WHERE

coderd/externalauth/externalauth.go

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ import (
2828
"github.com/coder/retry"
2929
)
3030

31+
const (
32+
failureReasonLimit = 200
33+
)
34+
3135
// Config is used for authentication for Git operations.
3236
type Config struct {
3337
promoauth.InstrumentedOAuth2Config
@@ -130,6 +134,12 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu
130134
refreshToken = ""
131135
}
132136

137+
if externalAuthLink.OauthRefreshFailureReason != "" {
138+
// If the refresh token is invalid, do not try to keep using it. This will
139+
// prevent spamming the IdP with refresh attempts that will fail.
140+
refreshToken = ""
141+
}
142+
133143
existingToken := &oauth2.Token{
134144
AccessToken: externalAuthLink.OAuthAccessToken,
135145
RefreshToken: refreshToken,
@@ -143,12 +153,19 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu
143153
// get rid of it. Keeping it around will cause additional refresh
144154
// attempts that will fail and cost us api rate limits.
145155
if isFailedRefresh(existingToken, err) {
156+
reason := err.Error()
157+
if len(reason) > failureReasonLimit {
158+
// Limit the length of the error message to prevent
159+
// spamming the database with long error messages.
160+
reason = reason[:failureReasonLimit]
161+
}
146162
dbExecErr := db.UpdateExternalAuthLinkRefreshToken(ctx, database.UpdateExternalAuthLinkRefreshTokenParams{
147-
OAuthRefreshToken: "", // It is better to clear the refresh token than to keep retrying.
148-
OAuthRefreshTokenKeyID: externalAuthLink.OAuthRefreshTokenKeyID.String,
149-
UpdatedAt: dbtime.Now(),
150-
ProviderID: externalAuthLink.ProviderID,
151-
UserID: externalAuthLink.UserID,
163+
// Adding a reason will prevent further attempts to try and refresh the token.
164+
OauthRefreshFailureReason: reason,
165+
OAuthRefreshTokenKeyID: externalAuthLink.OAuthRefreshTokenKeyID.String,
166+
UpdatedAt: dbtime.Now(),
167+
ProviderID: externalAuthLink.ProviderID,
168+
UserID: externalAuthLink.UserID,
152169
})
153170
if dbExecErr != nil {
154171
// This error should be rare.

coderd/externalauth/externalauth_test.go

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -177,37 +177,44 @@ func TestRefreshToken(t *testing.T) {
177177
link.OAuthExpiry = expired
178178

179179
// Make the failure a server internal error. Not related to the token
180+
// This should be retried since this error is temporary.
180181
refreshErr = &oauth2.RetrieveError{
181182
Response: &http.Response{
182183
StatusCode: http.StatusInternalServerError,
183184
},
184185
ErrorCode: "internal_error",
185186
}
186-
_, err := config.RefreshToken(ctx, mDB, link)
187-
require.Error(t, err)
188-
require.True(t, externalauth.IsInvalidTokenError(err))
189-
require.Equal(t, refreshCount, 1)
187+
totalRefreshes := 0
188+
for i := 0; i < 3; i++ {
189+
// Each loop will hit the temporary error and retry.
190+
_, err := config.RefreshToken(ctx, mDB, link)
191+
require.Error(t, err)
192+
totalRefreshes++
193+
require.True(t, externalauth.IsInvalidTokenError(err))
194+
require.Equal(t, refreshCount, totalRefreshes)
195+
}
190196

191-
// Try again with a bad refresh token error
192-
// Expect DB call to remove the refresh token
197+
// Try again with a bad refresh token error. This will invalidate the
198+
// refresh token, and not retry again. Expect DB call to remove the refresh token
193199
mDB.EXPECT().UpdateExternalAuthLinkRefreshToken(gomock.Any(), gomock.Any()).Return(nil).Times(1)
194200
refreshErr = &oauth2.RetrieveError{ // github error
195201
Response: &http.Response{
196202
StatusCode: http.StatusOK,
197203
},
198204
ErrorCode: "bad_refresh_token",
199205
}
200-
_, err = config.RefreshToken(ctx, mDB, link)
206+
_, err := config.RefreshToken(ctx, mDB, link)
201207
require.Error(t, err)
208+
totalRefreshes++
202209
require.True(t, externalauth.IsInvalidTokenError(err))
203-
require.Equal(t, refreshCount, 2)
210+
require.Equal(t, refreshCount, totalRefreshes)
204211

205212
// When the refresh token is empty, no api calls should be made
206213
link.OAuthRefreshToken = "" // mock'd db, so manually set the token to ''
207214
_, err = config.RefreshToken(ctx, mDB, link)
208215
require.Error(t, err)
209216
require.True(t, externalauth.IsInvalidTokenError(err))
210-
require.Equal(t, refreshCount, 2)
217+
require.Equal(t, refreshCount, totalRefreshes)
211218
})
212219

213220
// ValidateFailure tests if the token is no longer valid with a 401 response.

scripts/testidp/main.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"flag"
66
"log"
7+
"net/http"
78
"os"
89
"os/signal"
910
"strings"
@@ -13,6 +14,7 @@ import (
1314
"github.com/golang-jwt/jwt/v4"
1415
"github.com/google/uuid"
1516
"github.com/stretchr/testify/require"
17+
"golang.org/x/oauth2"
1618

1719
"cdr.dev/slog"
1820
"cdr.dev/slog/sloggers/sloghuman"
@@ -96,6 +98,17 @@ func RunIDP() func(t *testing.T) {
9698
"groups": []string{"testidp", "qa", "engineering"},
9799
"roles": []string{"testidp", "admin", "higher_power"},
98100
}),
101+
oidctest.WithRefresh(func(email string) error {
102+
return &oauth2.RetrieveError{
103+
Response: &http.Response{
104+
StatusCode: http.StatusBadRequest,
105+
},
106+
Body: nil,
107+
ErrorCode: "bad_refresh_token",
108+
ErrorDescription: "refreshing is set to fail for testing purposes",
109+
ErrorURI: "",
110+
}
111+
}),
99112
oidctest.WithDefaultIDClaims(jwt.MapClaims{}),
100113
oidctest.WithDefaultExpire(*expiry),
101114
oidctest.WithStaticCredentials(*clientID, *clientSecret),

0 commit comments

Comments
 (0)