- Notifications
You must be signed in to change notification settings - Fork913
chore: do not refresh tokens that have already failed refreshing#15608
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.
Already on GitHub?Sign in to your account
Uh oh!
There was an error while loading.Please reload this page.
Changes fromall commits
59b7ade
b3c5349
ad9112d
9e386b8
b57bb70
1fc5068
4aba228
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading.Please reload this page.
Jump to
Uh oh!
There was an error while loading.Please reload this page.
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -775,7 +775,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler { | ||
if f.hookWellKnown != nil { | ||
err := f.hookWellKnown(r, &cpy) | ||
if err != nil { | ||
httpError(rw, http.StatusInternalServerError, err) | ||
return | ||
} | ||
} | ||
@@ -792,7 +792,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler { | ||
clientID := r.URL.Query().Get("client_id") | ||
if !assert.Equal(t, f.clientID, clientID, "unexpected client_id") { | ||
httpError(rw,http.StatusBadRequest, xerrors.New("invalid client_id")) | ||
return | ||
} | ||
@@ -818,7 +818,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler { | ||
err := f.hookValidRedirectURL(redirectURI) | ||
if err != nil { | ||
t.Errorf("not authorized redirect_uri by custom hook %q: %s", redirectURI, err.Error()) | ||
httpError(rw,http.StatusBadRequest, xerrors.Errorf("invalid redirect_uri: %w", err)) | ||
return | ||
} | ||
@@ -853,7 +853,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler { | ||
)...) | ||
if err != nil { | ||
httpError(rw, http.StatusBadRequest, err) | ||
return | ||
} | ||
getEmail := func(claims jwt.MapClaims) string { | ||
@@ -914,7 +914,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler { | ||
claims = idTokenClaims | ||
err := f.hookOnRefresh(getEmail(claims)) | ||
if err != nil { | ||
httpError(rw,http.StatusBadRequest, xerrors.Errorf("refresh hook blocked refresh: %w", err)) | ||
return | ||
} | ||
@@ -1036,7 +1036,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler { | ||
claims, err := f.hookUserInfo(email) | ||
if err != nil { | ||
httpError(rw,http.StatusBadRequest, xerrors.Errorf("user info hook returned error: %w", err)) | ||
return | ||
} | ||
_ = json.NewEncoder(rw).Encode(claims) | ||
@@ -1499,13 +1499,33 @@ func slogRequestFields(r *http.Request) []any { | ||
} | ||
} | ||
// httpError handles better formatted custom errors. | ||
func httpError(rw http.ResponseWriter, defaultCode int, err error) { | ||
status := defaultCode | ||
var statusErr statusHookError | ||
if errors.As(err, &statusErr) { | ||
status = statusErr.HTTPStatusCode | ||
} | ||
var oauthErr *oauth2.RetrieveError | ||
if errors.As(err, &oauthErr) { | ||
if oauthErr.Response.StatusCode != 0 { | ||
status = oauthErr.Response.StatusCode | ||
} | ||
rw.Header().Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") | ||
form := url.Values{ | ||
"error": {oauthErr.ErrorCode}, | ||
"error_description": {oauthErr.ErrorDescription}, | ||
"error_uri": {oauthErr.ErrorURI}, | ||
} | ||
rw.WriteHeader(status) | ||
_, _ = rw.Write([]byte(form.Encode())) | ||
return | ||
} | ||
http.Error(rw, err.Error(), status) | ||
Comment on lines +1511 to +1528 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others.Learn more. Added this to the fake IDP to construct proper oauth errors. This allows me to send a status code 200 with an oauth error. This is how github does it, which feels kinda strange, but wanted to make sure we support that behavior. | ||
} | ||
type fakeRoundTripper struct { | ||
Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.
Uh oh!
There was an error while loading.Please reload this page.
Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.
Uh oh!
There was an error while loading.Please reload this page.
Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.
Uh oh!
There was an error while loading.Please reload this page.
Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.
Uh oh!
There was an error while loading.Please reload this page.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -118,7 +118,7 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu | ||
// This is true for github, which has no expiry. | ||
!externalAuthLink.OAuthExpiry.IsZero() && | ||
externalAuthLink.OAuthExpiry.Before(dbtime.Now()) { | ||
return externalAuthLink, InvalidTokenError("token expired, refreshing iseitherdisabled or refreshing failed and will not be retried") | ||
johnstcn marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
} | ||
// 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 | ||
refreshToken = "" | ||
} | ||
existingToken := &oauth2.Token{ | ||
AccessToken: externalAuthLink.OAuthAccessToken, | ||
RefreshToken: refreshToken, | ||
Expiry: externalAuthLink.OAuthExpiry, | ||
} | ||
token, err := c.TokenSource(ctx, existingToken).Token() | ||
if err != nil { | ||
// TokenSource can fail for numerous reasons. If it fails because of | ||
// a bad refresh token, then the refresh token is invalid, and we should | ||
// get rid of it. Keeping it around will cause additional refresh | ||
// attempts that will fail and cost us api rate limits. | ||
if isFailedRefresh(existingToken, err) { | ||
dbExecErr := db.RemoveRefreshToken(ctx, database.RemoveRefreshTokenParams{ | ||
UpdatedAt: dbtime.Now(), | ||
ProviderID: externalAuthLink.ProviderID, | ||
UserID: externalAuthLink.UserID, | ||
}) | ||
if dbExecErr != nil { | ||
// This error should be rare. | ||
return externalAuthLink, InvalidTokenError(fmt.Sprintf("refresh token failed: %q, then removing refresh token failed: %q", err.Error(), dbExecErr.Error())) | ||
} | ||
// The refresh token was cleared | ||
externalAuthLink.OAuthRefreshToken = "" | ||
} | ||
// Unfortunately have to match exactly on the error message string. | ||
// Improve the error message to account refresh tokens are deleted if | ||
// invalid on our end. | ||
if err.Error() == "oauth2: token expired and refresh token is not set" { | ||
return externalAuthLink, InvalidTokenError("token expired, refreshing is either disabled or refreshing failed and will not be retried") | ||
} | ||
// TokenSource(...).Token() will always return the current token if the token is not expired. | ||
// So this error is only returned if a refresh of the token failed. | ||
return externalAuthLink, InvalidTokenError(fmt.Sprintf("refresh token: %s", err.Error())) | ||
} | ||
@@ -973,3 +998,50 @@ func IsGithubDotComURL(str string) bool { | ||
} | ||
return ghURL.Host == "github.com" | ||
} | ||
// isFailedRefresh returns true if the error returned by the TokenSource.Token() | ||
// is due to a failed refresh. The failure being the refresh token itself. | ||
// If this returns true, no amount of retries will fix the issue. | ||
// | ||
// Notes: Provider responses are not uniform. Here are some examples: | ||
// Github | ||
// - Returns a 200 with Code "bad_refresh_token" and Description "The refresh token passed is incorrect or expired." | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others.Learn more. thisisfine.jpg | ||
// | ||
// Gitea [TODO: get an expired refresh token] | ||
// - [Bad JWT] Returns 400 with Code "unauthorized_client" and Description "unable to parse refresh token" | ||
// | ||
// Gitlab | ||
// - 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." | ||
func isFailedRefresh(existingToken *oauth2.Token, err error) bool { | ||
if existingToken.RefreshToken == "" { | ||
return false // No refresh token, so this cannot be refreshed | ||
} | ||
if existingToken.Valid() { | ||
return false // Valid tokens are not refreshed | ||
} | ||
var oauthErr *oauth2.RetrieveError | ||
if xerrors.As(err, &oauthErr) { | ||
switch oauthErr.ErrorCode { | ||
// Known error codes that indicate a failed refresh. | ||
// 'Spec' means the code is defined in the spec. | ||
case "bad_refresh_token", // Github | ||
"invalid_grant", // Gitlab & Spec | ||
"unauthorized_client", // Gitea & Spec | ||
"unsupported_grant_type": // Spec, refresh not supported | ||
return true | ||
} | ||
switch oauthErr.Response.StatusCode { | ||
case http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusOK: | ||
// Status codes that indicate the request was processed, and rejected. | ||
return true | ||
case http.StatusInternalServerError, http.StatusTooManyRequests: | ||
// These do not indicate a failed refresh, but could be a temporary issue. | ||
return false | ||
} | ||
} | ||
return false | ||
} |
Uh oh!
There was an error while loading.Please reload this page.
Uh oh!
There was an error while loading.Please reload this page.