Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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

Merged
Emyrk merged 7 commits intomainfromstevenmasley/refresh_token_spam
Nov 21, 2024
Merged
Show file tree
Hide file tree
Changes fromall 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
38 changes: 29 additions & 9 deletionscoderd/coderdtest/oidctest/idp.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
http.Error(rw,err.Error(),http.StatusInternalServerError)
httpError(rw, http.StatusInternalServerError, err)
return
}
}
Expand All@@ -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") {
http.Error(rw, "invalid client_id", http.StatusBadRequest)
httpError(rw,http.StatusBadRequest, xerrors.New("invalid client_id"))
return
}

Expand All@@ -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())
http.Error(rw,fmt.Sprintf("invalid redirect_uri: %s", err.Error()), httpErrorCode(http.StatusBadRequest, err))
httpError(rw,http.StatusBadRequest, xerrors.Errorf("invalid redirect_uri: %w", err))
return
}

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

if err != nil {
http.Error(rw,fmt.Sprintf("invalid token request: %s", err.Error()), httpErrorCode(http.StatusBadRequest, err))
httpError(rw, http.StatusBadRequest, err)
return
}
getEmail := func(claims jwt.MapClaims) string {
Expand DownExpand Up@@ -914,7 +914,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
claims = idTokenClaims
err := f.hookOnRefresh(getEmail(claims))
if err != nil {
http.Error(rw,fmt.Sprintf("refresh hook blocked refresh: %s", err.Error()), httpErrorCode(http.StatusBadRequest, err))
httpError(rw,http.StatusBadRequest, xerrors.Errorf("refresh hook blocked refresh: %w", err))
return
}

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

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

func httpErrorCode(defaultCode int, err error) int {
var statusErr statusHookError
// 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
}
return status

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
Copy link
MemberAuthor

Choose a reason for hiding this comment

The 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 {
Expand Down
7 changes: 7 additions & 0 deletionscoderd/database/dbauthz/dbauthz.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -3319,6 +3319,13 @@ func (q *querier) RegisterWorkspaceProxy(ctx context.Context, arg database.Regis
return updateWithReturn(q.log, q.auth, fetch, q.db.RegisterWorkspaceProxy)(ctx, arg)
}

func (q *querier) RemoveRefreshToken(ctx context.Context, arg database.RemoveRefreshTokenParams) error {
fetch := func(ctx context.Context, arg database.RemoveRefreshTokenParams) (database.ExternalAuthLink, error) {
return q.db.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{UserID: arg.UserID, ProviderID: arg.ProviderID})
}
return fetchAndExec(q.log, q.auth, policy.ActionUpdatePersonal, fetch, q.db.RemoveRefreshToken)(ctx, arg)
}

func (q *querier) RemoveUserFromAllGroups(ctx context.Context, userID uuid.UUID) error {
// This is a system function to clear user groups in group sync.
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
Expand Down
8 changes: 8 additions & 0 deletionscoderd/database/dbauthz/dbauthz_test.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -1269,6 +1269,14 @@ func (s *MethodTestSuite) TestUser() {
UserID: u.ID,
}).Asserts(u, policy.ActionUpdatePersonal)
}))
s.Run("RemoveRefreshToken", s.Subtest(func(db database.Store, check *expects) {
link := dbgen.ExternalAuthLink(s.T(), db, database.ExternalAuthLink{})
check.Args(database.RemoveRefreshTokenParams{
ProviderID: link.ProviderID,
UserID: link.UserID,
UpdatedAt: link.UpdatedAt,
}).Asserts(rbac.ResourceUserObject(link.UserID), policy.ActionUpdatePersonal)
}))
s.Run("UpdateExternalAuthLink", s.Subtest(func(db database.Store, check *expects) {
link := dbgen.ExternalAuthLink(s.T(), db, database.ExternalAuthLink{})
check.Args(database.UpdateExternalAuthLinkParams{
Expand Down
23 changes: 23 additions & 0 deletionscoderd/database/dbmem/dbmem.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -8512,6 +8512,29 @@ func (q *FakeQuerier) RegisterWorkspaceProxy(_ context.Context, arg database.Reg
return database.WorkspaceProxy{}, sql.ErrNoRows
}

func (q *FakeQuerier) RemoveRefreshToken(_ context.Context, arg database.RemoveRefreshTokenParams) error {
if err := validateDatabaseType(arg); err != nil {
return err
}

q.mutex.Lock()
defer q.mutex.Unlock()
for index, gitAuthLink := range q.externalAuthLinks {
if gitAuthLink.ProviderID != arg.ProviderID {
continue
}
if gitAuthLink.UserID != arg.UserID {
continue
}
gitAuthLink.UpdatedAt = arg.UpdatedAt
gitAuthLink.OAuthRefreshToken = ""
q.externalAuthLinks[index] = gitAuthLink

return nil
}
return sql.ErrNoRows
}

func (q *FakeQuerier) RemoveUserFromAllGroups(_ context.Context, userID uuid.UUID) error {
q.mutex.Lock()
defer q.mutex.Unlock()
Expand Down
7 changes: 7 additions & 0 deletionscoderd/database/dbmetrics/querymetrics.go
View file
Open in desktop

Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.

14 changes: 14 additions & 0 deletionscoderd/database/dbmock/dbmock.go
View file
Open in desktop

Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.

4 changes: 4 additions & 0 deletionscoderd/database/querier.go
View file
Open in desktop

Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.

23 changes: 23 additions & 0 deletionscoderd/database/queries.sql.go
View file
Open in desktop

Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.

11 changes: 11 additions & 0 deletionscoderd/database/queries/externalauth.sql
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,3 +42,14 @@ UPDATE external_auth_links SET
oauth_expiry = $8,
oauth_extra = $9
WHERE provider_id = $1 AND user_id = $2 RETURNING *;

-- name: RemoveRefreshToken :exec
-- Removing the refresh token disables the refresh behavior for a given
-- auth token. If a refresh token is marked invalid, it is better to remove it
-- then continually attempt to refresh the token.
UPDATE
external_auth_links
SET
oauth_refresh_token = '',
updated_at = @updated_at
WHERE provider_id = @provider_id AND user_id = @user_id;
84 changes: 78 additions & 6 deletionscoderd/externalauth/externalauth.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 is disabled")
return externalAuthLink, InvalidTokenError("token expired, refreshing iseitherdisabled or refreshing failed and will not be retried")
}

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

token, err := c.TokenSource(ctx, &oauth2.Token{
existingToken := &oauth2.Token{
AccessToken: externalAuthLink.OAuthAccessToken,
RefreshToken: refreshToken,
Expiry: externalAuthLink.OAuthExpiry,
}).Token()
}

token, err := c.TokenSource(ctx, existingToken).Token()
if err != nil {
// Even if the token fails to be obtained, do not return the error as an error.
// 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.
// If it is expired, it will attempt to refresh the token, and if it cannot, it will fail with
// an error. This error is a reason the token is invalid.
// So this error is only returned if a refresh of the token failed.
return externalAuthLink, InvalidTokenError(fmt.Sprintf("refresh token: %s", err.Error()))
}

Expand DownExpand Up@@ -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."
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

thisisfine.jpg

Emyrk reacted with laugh emoji
//
// 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
}
Loading
Loading

[8]ページ先頭

©2009-2025 Movatter.jp