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

Skip to content

Commit 78f9f43

Browse files
authored
chore: do not refresh tokens that have already failed refreshing (#15608)
Once a token refresh fails, we remove the `oauth_refresh_token` from the database. This will prevent the token from hitting the IDP for subsequent refresh attempts. Without this change, a bad script can cause a failing token to hit a remote IDP repeatedly with each `git` operation. With this change, after the first hit, subsequent hits will fail locally, and never contact the IDP. The solution in both cases is to authenticate the external auth link. So the resolution is the same as before.
1 parent dcbcf67 commit 78f9f43

File tree

11 files changed

+274
-16
lines changed

11 files changed

+274
-16
lines changed

coderd/coderdtest/oidctest/idp.go

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -775,7 +775,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
775775
if f.hookWellKnown != nil {
776776
err := f.hookWellKnown(r, &cpy)
777777
if err != nil {
778-
http.Error(rw, err.Error(), http.StatusInternalServerError)
778+
httpError(rw, http.StatusInternalServerError, err)
779779
return
780780
}
781781
}
@@ -792,7 +792,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
792792

793793
clientID := r.URL.Query().Get("client_id")
794794
if !assert.Equal(t, f.clientID, clientID, "unexpected client_id") {
795-
http.Error(rw, "invalid client_id", http.StatusBadRequest)
795+
httpError(rw, http.StatusBadRequest, xerrors.New("invalid client_id"))
796796
return
797797
}
798798

@@ -818,7 +818,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
818818
err := f.hookValidRedirectURL(redirectURI)
819819
if err != nil {
820820
t.Errorf("not authorized redirect_uri by custom hook %q: %s", redirectURI, err.Error())
821-
http.Error(rw, fmt.Sprintf("invalid redirect_uri: %s", err.Error()), httpErrorCode(http.StatusBadRequest, err))
821+
httpError(rw, http.StatusBadRequest, xerrors.Errorf("invalid redirect_uri: %w", err))
822822
return
823823
}
824824

@@ -853,7 +853,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
853853
)...)
854854

855855
if err != nil {
856-
http.Error(rw, fmt.Sprintf("invalid token request: %s", err.Error()), httpErrorCode(http.StatusBadRequest, err))
856+
httpError(rw, http.StatusBadRequest, err)
857857
return
858858
}
859859
getEmail := func(claims jwt.MapClaims) string {
@@ -914,7 +914,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
914914
claims = idTokenClaims
915915
err := f.hookOnRefresh(getEmail(claims))
916916
if err != nil {
917-
http.Error(rw, fmt.Sprintf("refresh hook blocked refresh: %s", err.Error()), httpErrorCode(http.StatusBadRequest, err))
917+
httpError(rw, http.StatusBadRequest, xerrors.Errorf("refresh hook blocked refresh: %w", err))
918918
return
919919
}
920920

@@ -1036,7 +1036,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
10361036

10371037
claims, err := f.hookUserInfo(email)
10381038
if err != nil {
1039-
http.Error(rw, fmt.Sprintf("user info hook returned error: %s", err.Error()), httpErrorCode(http.StatusBadRequest, err))
1039+
httpError(rw, http.StatusBadRequest, xerrors.Errorf("user info hook returned error: %w", err))
10401040
return
10411041
}
10421042
_ = json.NewEncoder(rw).Encode(claims)
@@ -1499,13 +1499,33 @@ func slogRequestFields(r *http.Request) []any {
14991499
}
15001500
}
15011501

1502-
func httpErrorCode(defaultCode int, err error) int {
1503-
var statusErr statusHookError
1502+
// httpError handles better formatted custom errors.
1503+
func httpError(rw http.ResponseWriter, defaultCode int, err error) {
15041504
status := defaultCode
1505+
1506+
var statusErr statusHookError
15051507
if errors.As(err, &statusErr) {
15061508
status = statusErr.HTTPStatusCode
15071509
}
1508-
return status
1510+
1511+
var oauthErr *oauth2.RetrieveError
1512+
if errors.As(err, &oauthErr) {
1513+
if oauthErr.Response.StatusCode != 0 {
1514+
status = oauthErr.Response.StatusCode
1515+
}
1516+
1517+
rw.Header().Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
1518+
form := url.Values{
1519+
"error": {oauthErr.ErrorCode},
1520+
"error_description": {oauthErr.ErrorDescription},
1521+
"error_uri": {oauthErr.ErrorURI},
1522+
}
1523+
rw.WriteHeader(status)
1524+
_, _ = rw.Write([]byte(form.Encode()))
1525+
return
1526+
}
1527+
1528+
http.Error(rw, err.Error(), status)
15091529
}
15101530

15111531
type fakeRoundTripper struct {

coderd/database/dbauthz/dbauthz.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3319,6 +3319,13 @@ func (q *querier) RegisterWorkspaceProxy(ctx context.Context, arg database.Regis
33193319
return updateWithReturn(q.log, q.auth, fetch, q.db.RegisterWorkspaceProxy)(ctx, arg)
33203320
}
33213321

3322+
func (q *querier) RemoveRefreshToken(ctx context.Context, arg database.RemoveRefreshTokenParams) error {
3323+
fetch := func(ctx context.Context, arg database.RemoveRefreshTokenParams) (database.ExternalAuthLink, error) {
3324+
return q.db.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{UserID: arg.UserID, ProviderID: arg.ProviderID})
3325+
}
3326+
return fetchAndExec(q.log, q.auth, policy.ActionUpdatePersonal, fetch, q.db.RemoveRefreshToken)(ctx, arg)
3327+
}
3328+
33223329
func (q *querier) RemoveUserFromAllGroups(ctx context.Context, userID uuid.UUID) error {
33233330
// This is a system function to clear user groups in group sync.
33243331
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {

coderd/database/dbauthz/dbauthz_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1269,6 +1269,14 @@ func (s *MethodTestSuite) TestUser() {
12691269
UserID: u.ID,
12701270
}).Asserts(u, policy.ActionUpdatePersonal)
12711271
}))
1272+
s.Run("RemoveRefreshToken", s.Subtest(func(db database.Store, check *expects) {
1273+
link := dbgen.ExternalAuthLink(s.T(), db, database.ExternalAuthLink{})
1274+
check.Args(database.RemoveRefreshTokenParams{
1275+
ProviderID: link.ProviderID,
1276+
UserID: link.UserID,
1277+
UpdatedAt: link.UpdatedAt,
1278+
}).Asserts(rbac.ResourceUserObject(link.UserID), policy.ActionUpdatePersonal)
1279+
}))
12721280
s.Run("UpdateExternalAuthLink", s.Subtest(func(db database.Store, check *expects) {
12731281
link := dbgen.ExternalAuthLink(s.T(), db, database.ExternalAuthLink{})
12741282
check.Args(database.UpdateExternalAuthLinkParams{

coderd/database/dbmem/dbmem.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8512,6 +8512,29 @@ func (q *FakeQuerier) RegisterWorkspaceProxy(_ context.Context, arg database.Reg
85128512
return database.WorkspaceProxy{}, sql.ErrNoRows
85138513
}
85148514

8515+
func (q *FakeQuerier) RemoveRefreshToken(_ context.Context, arg database.RemoveRefreshTokenParams) error {
8516+
if err := validateDatabaseType(arg); err != nil {
8517+
return err
8518+
}
8519+
8520+
q.mutex.Lock()
8521+
defer q.mutex.Unlock()
8522+
for index, gitAuthLink := range q.externalAuthLinks {
8523+
if gitAuthLink.ProviderID != arg.ProviderID {
8524+
continue
8525+
}
8526+
if gitAuthLink.UserID != arg.UserID {
8527+
continue
8528+
}
8529+
gitAuthLink.UpdatedAt = arg.UpdatedAt
8530+
gitAuthLink.OAuthRefreshToken = ""
8531+
q.externalAuthLinks[index] = gitAuthLink
8532+
8533+
return nil
8534+
}
8535+
return sql.ErrNoRows
8536+
}
8537+
85158538
func (q *FakeQuerier) RemoveUserFromAllGroups(_ context.Context, userID uuid.UUID) error {
85168539
q.mutex.Lock()
85178540
defer q.mutex.Unlock()

coderd/database/dbmetrics/querymetrics.go

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

coderd/database/dbmock/dbmock.go

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

coderd/database/querier.go

Lines changed: 4 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: 23 additions & 0 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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,14 @@ UPDATE external_auth_links SET
4242
oauth_expiry = $8,
4343
oauth_extra = $9
4444
WHERE provider_id = $1 AND user_id = $2 RETURNING *;
45+
46+
-- name: RemoveRefreshToken :exec
47+
-- Removing the refresh token disables the refresh behavior for a given
48+
-- auth token. If a refresh token is marked invalid, it is better to remove it
49+
-- then continually attempt to refresh the token.
50+
UPDATE
51+
external_auth_links
52+
SET
53+
oauth_refresh_token = '',
54+
updated_at = @updated_at
55+
WHERE provider_id = @provider_id AND user_id = @user_id;

coderd/externalauth/externalauth.go

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu
118118
// This is true for github, which has no expiry.
119119
!externalAuthLink.OAuthExpiry.IsZero() &&
120120
externalAuthLink.OAuthExpiry.Before(dbtime.Now()) {
121-
return externalAuthLink, InvalidTokenError("token expired, refreshing is disabled")
121+
return externalAuthLink, InvalidTokenError("token expired, refreshing is either disabled or refreshing failed and will not be retried")
122122
}
123123

124124
// This is additional defensive programming. Because TokenSource is an interface,
@@ -130,16 +130,41 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu
130130
refreshToken = ""
131131
}
132132

133-
token, err := c.TokenSource(ctx, &oauth2.Token{
133+
existingToken := &oauth2.Token{
134134
AccessToken: externalAuthLink.OAuthAccessToken,
135135
RefreshToken: refreshToken,
136136
Expiry: externalAuthLink.OAuthExpiry,
137-
}).Token()
137+
}
138+
139+
token, err := c.TokenSource(ctx, existingToken).Token()
138140
if err != nil {
139-
// Even if the token fails to be obtained, do not return the error as an error.
141+
// TokenSource can fail for numerous reasons. If it fails because of
142+
// a bad refresh token, then the refresh token is invalid, and we should
143+
// get rid of it. Keeping it around will cause additional refresh
144+
// attempts that will fail and cost us api rate limits.
145+
if isFailedRefresh(existingToken, err) {
146+
dbExecErr := db.RemoveRefreshToken(ctx, database.RemoveRefreshTokenParams{
147+
UpdatedAt: dbtime.Now(),
148+
ProviderID: externalAuthLink.ProviderID,
149+
UserID: externalAuthLink.UserID,
150+
})
151+
if dbExecErr != nil {
152+
// This error should be rare.
153+
return externalAuthLink, InvalidTokenError(fmt.Sprintf("refresh token failed: %q, then removing refresh token failed: %q", err.Error(), dbExecErr.Error()))
154+
}
155+
// The refresh token was cleared
156+
externalAuthLink.OAuthRefreshToken = ""
157+
}
158+
159+
// Unfortunately have to match exactly on the error message string.
160+
// Improve the error message to account refresh tokens are deleted if
161+
// invalid on our end.
162+
if err.Error() == "oauth2: token expired and refresh token is not set" {
163+
return externalAuthLink, InvalidTokenError("token expired, refreshing is either disabled or refreshing failed and will not be retried")
164+
}
165+
140166
// TokenSource(...).Token() will always return the current token if the token is not expired.
141-
// If it is expired, it will attempt to refresh the token, and if it cannot, it will fail with
142-
// an error. This error is a reason the token is invalid.
167+
// So this error is only returned if a refresh of the token failed.
143168
return externalAuthLink, InvalidTokenError(fmt.Sprintf("refresh token: %s", err.Error()))
144169
}
145170

@@ -973,3 +998,50 @@ func IsGithubDotComURL(str string) bool {
973998
}
974999
return ghURL.Host == "github.com"
9751000
}
1001+
1002+
// isFailedRefresh returns true if the error returned by the TokenSource.Token()
1003+
// is due to a failed refresh. The failure being the refresh token itself.
1004+
// If this returns true, no amount of retries will fix the issue.
1005+
//
1006+
// Notes: Provider responses are not uniform. Here are some examples:
1007+
// Github
1008+
// - Returns a 200 with Code "bad_refresh_token" and Description "The refresh token passed is incorrect or expired."
1009+
//
1010+
// Gitea [TODO: get an expired refresh token]
1011+
// - [Bad JWT] Returns 400 with Code "unauthorized_client" and Description "unable to parse refresh token"
1012+
//
1013+
// Gitlab
1014+
// - Returns 400 with Code "invalid_grant" and Description "The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client."
1015+
func isFailedRefresh(existingToken *oauth2.Token, err error) bool {
1016+
if existingToken.RefreshToken == "" {
1017+
return false // No refresh token, so this cannot be refreshed
1018+
}
1019+
1020+
if existingToken.Valid() {
1021+
return false // Valid tokens are not refreshed
1022+
}
1023+
1024+
var oauthErr *oauth2.RetrieveError
1025+
if xerrors.As(err, &oauthErr) {
1026+
switch oauthErr.ErrorCode {
1027+
// Known error codes that indicate a failed refresh.
1028+
// 'Spec' means the code is defined in the spec.
1029+
case "bad_refresh_token", // Github
1030+
"invalid_grant", // Gitlab & Spec
1031+
"unauthorized_client", // Gitea & Spec
1032+
"unsupported_grant_type": // Spec, refresh not supported
1033+
return true
1034+
}
1035+
1036+
switch oauthErr.Response.StatusCode {
1037+
case http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusOK:
1038+
// Status codes that indicate the request was processed, and rejected.
1039+
return true
1040+
case http.StatusInternalServerError, http.StatusTooManyRequests:
1041+
// These do not indicate a failed refresh, but could be a temporary issue.
1042+
return false
1043+
}
1044+
}
1045+
1046+
return false
1047+
}

0 commit comments

Comments
 (0)