diff --git a/openapi/oauth/guard.go b/openapi/oauth/guard.go index 2bb3110e..d02135aa 100644 --- a/openapi/oauth/guard.go +++ b/openapi/oauth/guard.go @@ -67,14 +67,21 @@ func (s *Service) Authenticate(c *gin.Context) bool { // Signature valid but expired — attempt auto refresh if !expiredClaims.ExpiresAt.IsZero() && expiredClaims.ExpiresAt.Before(time.Now()) { - newClaims, refreshErr := s.TryRefreshToken(c, expiredClaims) - if refreshErr != nil { - log.Error("[OAuth] Token refresh failed: %v", refreshErr) - response.RespondWithError(c, http.StatusUnauthorized, types.ErrInvalidRefreshToken) - c.Abort() - return false + if refreshToken := s.getRefreshToken(c); s.IsRefreshing(refreshToken) { + // Another request is already rotating this refresh token. + // The signature has been verified, so we can safely let + // this request through with the expired-but-authentic claims. + claims = expiredClaims + } else { + newClaims, refreshErr := s.TryRefreshToken(c, expiredClaims) + if refreshErr != nil { + log.Error("[OAuth] Token refresh failed: %v", refreshErr) + response.RespondWithError(c, http.StatusUnauthorized, types.ErrInvalidRefreshToken) + c.Abort() + return false + } + claims = newClaims } - claims = newClaims } else { response.RespondWithError(c, http.StatusUnauthorized, types.ErrInvalidToken) c.Abort() @@ -104,6 +111,13 @@ func (s *Service) TryRefreshToken(c *gin.Context, expiredClaims *types.TokenClai return nil, fmt.Errorf("refresh token missing") } + // Mark this refresh token as being rotated so concurrent requests + // can detect the in-flight rotation and skip duplicate refresh attempts. + // TTL acts as a safety net in case defer doesn't run (e.g. process crash). + refreshingKey := s.refreshingCacheKey(refreshToken) + s.cache.Set(refreshingKey, true, 30*time.Second) + defer s.cache.Del(refreshingKey) + refreshClaims, err := s.VerifyRefreshToken(refreshToken) if err != nil { return nil, fmt.Errorf("invalid or expired refresh token: %w", err) @@ -244,6 +258,21 @@ func (s *Service) GetRefreshToken(c *gin.Context) string { return s.getRefreshToken(c) } +// IsRefreshing reports whether the given refresh token is currently being +// rotated by another request. Callers can use this to avoid duplicate +// refresh attempts that would fail because the old token has been revoked. +func (s *Service) IsRefreshing(refreshToken string) bool { + if refreshToken == "" { + return false + } + _, ok := s.cache.Get(s.refreshingCacheKey(refreshToken)) + return ok +} + +func (s *Service) refreshingCacheKey(refreshToken string) string { + return fmt.Sprintf("%soauth:refreshing:%s", s.prefix, refreshToken) +} + // GetSessionID gets the session ID from the request (public method) func (s *Service) GetSessionID(c *gin.Context) string { return s.getSessionID(c) diff --git a/sui/api/guards.go b/sui/api/guards.go index 8332af41..9279acef 100644 --- a/sui/api/guards.go +++ b/sui/api/guards.go @@ -122,11 +122,18 @@ func guardOAuth(r *Request) error { expiredClaims, expErr := oauth.OAuth.VerifyTokenAllowExpired(token) if expErr == nil && expiredClaims != nil && !expiredClaims.ExpiresAt.IsZero() && expiredClaims.ExpiresAt.Before(time.Now()) { - refreshed, refreshErr := oauth.OAuth.TryRefreshToken(c, expiredClaims) - if refreshErr != nil { - return fmt.Errorf("Exception|401:Token expired and refresh failed") + if refreshToken := oauth.OAuth.GetRefreshToken(c); oauth.OAuth.IsRefreshing(refreshToken) { + // Another request is already rotating this refresh token. + // The signature has been verified, so we can safely let + // this request through with the expired-but-authentic claims. + claims = expiredClaims + } else { + refreshed, refreshErr := oauth.OAuth.TryRefreshToken(c, expiredClaims) + if refreshErr != nil { + return fmt.Errorf("Exception|401:Token expired and refresh failed") + } + claims = refreshed } - claims = refreshed } else { return fmt.Errorf("Exception|401:Invalid token") }