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

Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions cli/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -974,6 +974,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
}

options.ExternalAuthConfigs, err = externalauth.ConvertConfig(
logger,
oauthInstrument,
mergedExternalAuthProviders,
vals.AccessURL.Value(),
Expand Down
91 changes: 63 additions & 28 deletions coderd/externalauth/externalauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"time"

"github.com/dustin/go-humanize"
Expand All @@ -22,11 +23,13 @@ import (
"golang.org/x/sync/singleflight"
"golang.org/x/xerrors"

"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/externalauth/gitprovider"
"github.com/coder/coder/v2/coderd/promoauth"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/coderd/util/xhttp"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/retry"
)
Expand Down Expand Up @@ -63,6 +66,10 @@ type SingleflightGroup interface {
// Config is used for authentication for Git operations.
type Config struct {
promoauth.InstrumentedOAuth2Config
// Logs rate-limited validation warnings. Zero value discards output.
Logger slog.Logger
// rateLimitLogThrottle throttles rate-limited validation warnings.
rateLimitLogThrottle logThrottle
// ID is a unique identifier for the authenticator.
ID string
// Type is the type of provider.
Expand Down Expand Up @@ -520,7 +527,8 @@ func (c *Config) ValidateToken(ctx context.Context, link *oauth2.Token) (bool, *
// validation endpoint is rejecting for a transient reason.
// Treat it as optimistically valid rather than discarding
// the token.
if isRateLimited(res) {
if xhttp.IsRateLimited(res) {
c.logRateLimitedValidation(ctx, http.StatusForbidden, "rate_limit_headers")
return true, nil, nil
}
// No rate-limit headers: genuine token revocation or
Expand All @@ -532,6 +540,7 @@ func (c *Config) ValidateToken(ctx context.Context, link *oauth2.Token) (bool, *
// Treat 429 the same as a rate-limited 403: optimistically
// valid. The token was likely just issued by the IDP; the
// validation endpoint is transiently overloaded.
c.logRateLimitedValidation(ctx, http.StatusTooManyRequests, "status_code")
return true, nil, nil

case http.StatusOK:
Expand Down Expand Up @@ -560,6 +569,57 @@ func (c *Config) ValidateToken(ctx context.Context, link *oauth2.Token) (bool, *
return true, user, nil
}

// rateLimitLogInterval is the minimum time between rate-limited validation
// warnings emitted per Config.
const rateLimitLogInterval = time.Minute

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: I wonder if we should use time.Minute as the default while allowing users to override it with another value.


// logRateLimitedValidation warns that a token was kept valid without
// provider confirmation due to a rate-limited response. At most one
// warning is emitted per Config per rateLimitLogInterval; the line
// carries the number of occurrences suppressed since the previous one.
func (c *Config) logRateLimitedValidation(ctx context.Context, statusCode int, reason string) {
suppressed, ok := c.rateLimitLogThrottle.shouldLog(time.Now(), rateLimitLogInterval)
if !ok {
return
}
c.Logger.Warn(ctx, "external auth validation endpoint rate-limited; keeping token without provider confirmation",
Comment thread
jscottmiller marked this conversation as resolved.
slog.F("status_code", statusCode),
slog.F("reason", reason),
slog.F("suppressed", suppressed),
)
}

// logThrottle allows one event per interval and counts the events
// suppressed in between. Safe for concurrent use; the zero value is
// ready for use.
type logThrottle struct {
mu sync.Mutex
lastLog time.Time
suppressed int64
}

// shouldLog reports whether an event occurring at now may be logged,
// allowing at most one event per interval. When it returns true, it also
// returns the number of events suppressed since the last allowed one;
// if two or more intervals have elapsed, the stale count is discarded
// and zero is returned.
func (t *logThrottle) shouldLog(now time.Time, interval time.Duration) (int64, bool) {
t.mu.Lock()
defer t.mu.Unlock()
sinceLast := now.Sub(t.lastLog)
if sinceLast < interval {
t.suppressed++
return 0, false
}
n := t.suppressed
if sinceLast >= 2*interval {
n = 0
}
t.suppressed = 0
t.lastLog = now
return n, true
}

type AppInstallation struct {
ID int
// Login is the username of the installation.
Expand Down Expand Up @@ -852,7 +912,7 @@ func (c *DeviceAuth) formatDeviceCodeURL() (string, error) {

// ConvertConfig converts the SDK configuration entry format
// to the parsed and ready-to-consume in coderd provider type.
func ConvertConfig(instrument *promoauth.Factory, entries []codersdk.ExternalAuthConfig, accessURL *url.URL) ([]*Config, error) {
func ConvertConfig(logger slog.Logger, instrument *promoauth.Factory, entries []codersdk.ExternalAuthConfig, accessURL *url.URL) ([]*Config, error) {
ids := map[string]struct{}{}
configs := []*Config{}
for _, entry := range entries {
Expand Down Expand Up @@ -936,6 +996,7 @@ func ConvertConfig(instrument *promoauth.Factory, entries []codersdk.ExternalAut

cfg := &Config{
InstrumentedOAuth2Config: instrumented,
Logger: logger.Named("externalauth").With(slog.F("provider_id", entry.ID), slog.F("provider_type", entry.Type)),
ID: entry.ID,
ClientID: entry.ClientID,
ClientSecret: entry.ClientSecret,
Expand Down Expand Up @@ -1483,32 +1544,6 @@ func IsGithubDotComURL(str string) bool {
return ghURL.Host == "github.com"
}

// isRateLimited checks whether an HTTP response indicates a rate
// limit rather than a genuine authorization failure. It returns
// true if either X-RateLimit-Remaining is "0" (primary) or
// Retry-After is present (secondary). OR logic is intentional:
// GitHub secondary limits can include Retry-After without
// X-RateLimit-Remaining: 0 (the remaining count tracks the
// primary quota, not secondary).
//
// Does not catch every secondary rate limit. GitHub can return
// 403 with positive X-RateLimit-Remaining and no Retry-After.
// Reliable detection of those requires response body inspection.
// Missing them is not a regression since all 403s were previously
// treated as invalid.
func isRateLimited(resp *http.Response) bool {
if resp == nil {
return false
}
if resp.Header.Get("Retry-After") != "" {
return true
}
if resp.Header.Get("X-RateLimit-Remaining") == "0" {
return true
}
return false
}

// isFailedRefresh returns true if the error returned by the refresh attempt
// 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.
Expand Down
92 changes: 92 additions & 0 deletions coderd/externalauth/externalauth_internal_test.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,110 @@
package externalauth

import (
"bytes"
"context"
"encoding/json"
"net/http"
"sync"
"sync/atomic"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"

"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/slogjson"
"github.com/coder/coder/v2/coderd/promoauth"
"github.com/coder/coder/v2/codersdk"
)

func TestLogThrottle(t *testing.T) {
t.Parallel()

const interval = time.Minute
var th logThrottle
start := time.Now()

suppressed, ok := th.shouldLog(start, interval)
require.True(t, ok, "the first event should log")
require.EqualValues(t, 0, suppressed)

for i := range 3 {
_, ok := th.shouldLog(start.Add(time.Duration(i+1)*time.Second), interval)
require.False(t, ok, "events within the interval should be suppressed")
}
_, ok = th.shouldLog(start.Add(interval-time.Millisecond), interval)
require.False(t, ok, "an event just inside the interval should be suppressed")

suppressed, ok = th.shouldLog(start.Add(interval), interval)
require.True(t, ok, "the first event after the interval should log")
require.EqualValues(t, 4, suppressed, "suppressed should count events since the last log")

suppressed, ok = th.shouldLog(start.Add(2*interval), interval)
require.True(t, ok)
require.EqualValues(t, 0, suppressed, "suppressed should reset after each log")

// Suppress one event, then let more than two intervals elapse.
_, ok = th.shouldLog(start.Add(2*interval+time.Second), interval)
require.False(t, ok)
suppressed, ok = th.shouldLog(start.Add(5*interval), interval)
require.True(t, ok)
require.EqualValues(t, 0, suppressed, "counts from a burst that ended more than an interval ago are discarded")
}

func TestLogThrottleConcurrent(t *testing.T) {
t.Parallel()

const (
interval = time.Minute
events = 32
)
var th logThrottle
now := time.Now()

var (
wg sync.WaitGroup
logged atomic.Int64
)
for range events {
wg.Go(func() {
if _, ok := th.shouldLog(now, interval); ok {
logged.Add(1)
}
})
}
wg.Wait()
require.EqualValues(t, 1, logged.Load(), "exactly one concurrent event should log")

suppressed, ok := th.shouldLog(now.Add(interval), interval)
require.True(t, ok)
require.EqualValues(t, events-1, suppressed, "every other concurrent event should be counted")
}

// TestLogRateLimitedValidationSuppressed verifies the suppressed count
// reaches the emitted log line.
func TestLogRateLimitedValidationSuppressed(t *testing.T) {
t.Parallel()

logs := &bytes.Buffer{}
c := &Config{Logger: slog.Make(slogjson.Sink(logs)).Leveled(slog.LevelDebug)}
c.rateLimitLogThrottle.lastLog = time.Now().Add(-rateLimitLogInterval - time.Second)
c.rateLimitLogThrottle.suppressed = 5

c.logRateLimitedValidation(context.Background(), http.StatusTooManyRequests, "status_code")

var entry struct {
Fields struct {
Suppressed *int64 `json:"suppressed"`
} `json:"fields"`
}
require.NoError(t, json.Unmarshal(logs.Bytes(), &entry))
require.NotNil(t, entry.Fields.Suppressed, "the log line should carry the suppressed field")
require.EqualValues(t, 5, *entry.Fields.Suppressed)
}

func TestGitlabDefaults(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading