mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
When `ValidateToken` keeps a token because the external auth validation
endpoint was rate-limited (a `403` with rate-limit headers or a `429`),
it returns `valid=true` without provider confirmation. Previously this
happened silently, so operators couldn't tell a provider-confirmed token
from one kept optimistically during a rate limit.
This adds a `Logger` to `externalauth.Config` and emits a `Warn` (with
`provider_id`, `provider_type`, `status_code`, and `reason`) on those
rate-limit branches. It also adds a
`coderd_oauth2_external_requests_rate_limited_total{name, source,
status_code}` counter, incremented in the instrumented round tripper
whenever a provider returns a rate-limited response. The rate-limit
detection is the shared `xhttp.IsRateLimited` (in `coderd/util/xhttp`),
used by both the tripper and `ValidateToken` so the metric and the
validation decision share one definition; no extra wiring is needed
since `ValidateToken` already routes through the instrumented client
with `source="ValidateToken"`.
One deliberate behavioral change rides along: rate-limit detection now
also recognizes the unprefixed `RateLimit-Remaining` header (GitLab, and
the IETF draft rate-limit headers), so a `403` with
`RateLimit-Remaining: 0` is treated as optimistically valid where it was
previously treated as revoked. All other valid/invalid decisions are
unchanged. `TestValidateToken` asserts the warning's fields on the
rate-limited cases and no warning for revocations, `401`, and confirmed
responses; `promoauth` and `xhttp` tests cover the detector and the new
counter.
<details>
<summary>Manual testing</summary>
The signals fire on the external-auth status check (`GET
/api/v2/external-auth/{id}`), which calls `ValidateToken`. To force a
rate-limited response, point a provider's `validate_url` at a mock that
returns the rate-limit shape:
1. Run a mock returning `429` on one path and `403` +
`X-RateLimit-Remaining: 0` on another.
2. Start `coder server` with `--prometheus-enable` and external auth
providers whose `validate_url` point at those mock paths (e.g.
`CODER_EXTERNAL_AUTH_0_VALIDATE_URL=http://127.0.0.1:5599/429`).
3. Create a stored link, either complete the OAuth flow, or insert a row
into `external_auth_links` with a future `oauth_expiry` (token contents
are irrelevant; the mock rejects regardless).
4. `curl` the status endpoint with a session token, then check:
- coderd logs for the `Warn` (`reason=status_code` for `429`,
`reason=rate_limit_headers` for `403`),
- the metrics endpoint for
`coderd_oauth2_external_requests_rate_limited_total{...,status_code="429"|"403"}`.
Notes: `scripts/testidp -429` only rate-limits `/oauth2/userinfo`, not
the `/external-auth-validate/...` path, so it does not exercise this;
use a mock `validate_url`. The default Prometheus port `2112` may
already be taken on dogfood workspaces, set `CODER_PROMETHEUS_ADDRESS`
to a free port.
</details>
🤖 Generated with the help of Coder Agents on behalf of @jscottmiller.
421 lines
14 KiB
Go
421 lines
14 KiB
Go
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()
|
|
|
|
// The default cloud setup. Copying this here as hard coded
|
|
// values.
|
|
cloud := func() codersdk.ExternalAuthConfig {
|
|
return codersdk.ExternalAuthConfig{
|
|
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
|
|
ID: string(codersdk.EnhancedExternalAuthProviderGitLab),
|
|
AuthURL: "https://gitlab.com/oauth/authorize",
|
|
TokenURL: "https://gitlab.com/oauth/token",
|
|
ValidateURL: "https://gitlab.com/oauth/token/info",
|
|
RevokeURL: "https://gitlab.com/oauth/revoke",
|
|
DisplayName: "GitLab",
|
|
DisplayIcon: "/icon/gitlab.svg",
|
|
Regex: `^(https?://)?gitlab\.com(/.*)?$`,
|
|
APIBaseURL: "https://gitlab.com/api/v4",
|
|
Scopes: []string{"write_repository", "read_api"},
|
|
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodSha256)},
|
|
}
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
input codersdk.ExternalAuthConfig
|
|
expected codersdk.ExternalAuthConfig
|
|
mutateExpected func(*codersdk.ExternalAuthConfig)
|
|
}{
|
|
// Cloud
|
|
{
|
|
name: "OnlyType",
|
|
input: codersdk.ExternalAuthConfig{
|
|
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
|
|
},
|
|
expected: cloud(),
|
|
},
|
|
{
|
|
// If someone was to manually configure the gitlab cli.
|
|
name: "CloudByConfig",
|
|
input: codersdk.ExternalAuthConfig{
|
|
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
|
|
AuthURL: "https://gitlab.com/oauth/authorize",
|
|
},
|
|
expected: cloud(),
|
|
},
|
|
{
|
|
// Changing some of the defaults of the cloud option
|
|
name: "CloudWithChanges",
|
|
input: codersdk.ExternalAuthConfig{
|
|
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
|
|
// Adding an extra query param intentionally to break simple
|
|
// string comparisons.
|
|
AuthURL: "https://gitlab.com/oauth/authorize?foo=bar",
|
|
DisplayName: "custom",
|
|
Regex: ".*",
|
|
},
|
|
expected: cloud(),
|
|
mutateExpected: func(config *codersdk.ExternalAuthConfig) {
|
|
config.AuthURL = "https://gitlab.com/oauth/authorize?foo=bar"
|
|
config.DisplayName = "custom"
|
|
config.Regex = ".*"
|
|
},
|
|
},
|
|
// Self-hosted
|
|
{
|
|
// Dynamically figures out the Validate, Token, and Regex fields.
|
|
name: "SelfHostedOnlyAuthURL",
|
|
input: codersdk.ExternalAuthConfig{
|
|
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
|
|
AuthURL: "https://gitlab.company.org/oauth/authorize?foo=bar",
|
|
},
|
|
expected: cloud(),
|
|
mutateExpected: func(config *codersdk.ExternalAuthConfig) {
|
|
config.AuthURL = "https://gitlab.company.org/oauth/authorize?foo=bar"
|
|
config.ValidateURL = "https://gitlab.company.org/oauth/token/info"
|
|
config.TokenURL = "https://gitlab.company.org/oauth/token"
|
|
config.RevokeURL = "https://gitlab.company.org/oauth/revoke"
|
|
config.Regex = `^(https?://)?gitlab\.company\.org(/.*)?$`
|
|
config.APIBaseURL = "https://gitlab.company.org/api/v4"
|
|
},
|
|
},
|
|
{
|
|
// Strange values
|
|
name: "RandomValues",
|
|
input: codersdk.ExternalAuthConfig{
|
|
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
|
|
AuthURL: "https://auth.com/auth",
|
|
ValidateURL: "https://validate.com/validate",
|
|
TokenURL: "https://token.com/token",
|
|
RevokeURL: "https://token.com/revoke",
|
|
Regex: "random",
|
|
CodeChallengeMethodsSupported: []string{"random"},
|
|
},
|
|
expected: cloud(),
|
|
mutateExpected: func(config *codersdk.ExternalAuthConfig) {
|
|
config.AuthURL = "https://auth.com/auth"
|
|
config.ValidateURL = "https://validate.com/validate"
|
|
config.TokenURL = "https://token.com/token"
|
|
config.RevokeURL = "https://token.com/revoke"
|
|
config.Regex = `random`
|
|
config.CodeChallengeMethodsSupported = []string{"random"}
|
|
config.APIBaseURL = "https://auth.com/api/v4"
|
|
},
|
|
},
|
|
}
|
|
for _, c := range tests {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
applyDefaultsToConfig(&c.input)
|
|
if c.mutateExpected != nil {
|
|
c.mutateExpected(&c.expected)
|
|
}
|
|
require.Equal(t, c.input, c.expected)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsFailedRefresh(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
expiredToken := &oauth2.Token{
|
|
RefreshToken: "refresh-token",
|
|
// isFailedRefresh returns early at the existingToken.Valid()
|
|
// guard if the token is valid. Valid() requires
|
|
// AccessToken != "" AND not expired. This fixture has no
|
|
// AccessToken so Valid() is always false, but we set an
|
|
// expired time as a safety net in case someone later adds
|
|
// an AccessToken field.
|
|
Expiry: time.Now().Add(-time.Hour),
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "IncorrectClientCredentials_StatusOK",
|
|
err: &oauth2.RetrieveError{
|
|
Response: &http.Response{StatusCode: http.StatusOK},
|
|
ErrorCode: "incorrect_client_credentials",
|
|
},
|
|
// StatusOK fallthrough also returns true, so this test
|
|
// documents the combined behavior. See the 403-status
|
|
// variant below for error-code-only isolation.
|
|
expected: true,
|
|
},
|
|
{
|
|
// Uses 403 status (excluded from the status code switch)
|
|
// so the only path to true is the error code switch.
|
|
name: "IncorrectClientCredentials_Status403",
|
|
err: &oauth2.RetrieveError{
|
|
Response: &http.Response{StatusCode: http.StatusForbidden},
|
|
ErrorCode: "incorrect_client_credentials",
|
|
},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "InvalidClient_Status401",
|
|
err: &oauth2.RetrieveError{
|
|
Response: &http.Response{StatusCode: http.StatusUnauthorized},
|
|
ErrorCode: "invalid_client",
|
|
},
|
|
// StatusUnauthorized fallthrough also returns true, so
|
|
// this test documents the combined behavior.
|
|
expected: true,
|
|
},
|
|
{
|
|
// Uses 403 status (excluded from the status code switch)
|
|
// so the only path to true is the error code switch.
|
|
name: "InvalidClient_Status403",
|
|
err: &oauth2.RetrieveError{
|
|
Response: &http.Response{StatusCode: http.StatusForbidden},
|
|
ErrorCode: "invalid_client",
|
|
},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "UnknownErrorCode_Status403_Transient",
|
|
err: &oauth2.RetrieveError{
|
|
Response: &http.Response{StatusCode: http.StatusForbidden},
|
|
ErrorCode: "unknown_code",
|
|
},
|
|
// 403 with unknown error code should be transient (safe
|
|
// default: retry rather than destroy the token).
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := isFailedRefresh(expiredToken, tt.err)
|
|
assert.Equal(t, tt.expected, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_bitbucketServerConfigDefaults(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
bbType := string(codersdk.EnhancedExternalAuthProviderBitBucketServer)
|
|
tests := []struct {
|
|
name string
|
|
config *codersdk.ExternalAuthConfig
|
|
expected codersdk.ExternalAuthConfig
|
|
}{
|
|
{
|
|
// Very few fields are statically defined for Bitbucket Server.
|
|
name: "EmptyBitbucketServer",
|
|
config: &codersdk.ExternalAuthConfig{
|
|
Type: bbType,
|
|
},
|
|
expected: codersdk.ExternalAuthConfig{
|
|
Type: bbType,
|
|
ID: bbType,
|
|
DisplayName: "Bitbucket Server",
|
|
Scopes: []string{"PUBLIC_REPOS", "REPO_READ", "REPO_WRITE"},
|
|
DisplayIcon: "/icon/bitbucket.svg",
|
|
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
|
|
},
|
|
},
|
|
{
|
|
// Only the AuthURL is required for defaults to work.
|
|
name: "AuthURL",
|
|
config: &codersdk.ExternalAuthConfig{
|
|
Type: bbType,
|
|
AuthURL: "https://bitbucket.example.com/login/oauth/authorize",
|
|
},
|
|
expected: codersdk.ExternalAuthConfig{
|
|
Type: bbType,
|
|
ID: bbType,
|
|
AuthURL: "https://bitbucket.example.com/login/oauth/authorize",
|
|
TokenURL: "https://bitbucket.example.com/rest/oauth2/latest/token",
|
|
ValidateURL: "https://bitbucket.example.com/rest/api/latest/inbox/pull-requests/count",
|
|
Scopes: []string{"PUBLIC_REPOS", "REPO_READ", "REPO_WRITE"},
|
|
Regex: `^(https?://)?bitbucket\.example\.com(/.*)?$`,
|
|
DisplayName: "Bitbucket Server",
|
|
DisplayIcon: "/icon/bitbucket.svg",
|
|
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
|
|
},
|
|
},
|
|
{
|
|
// Ensure backwards compatibility. The type should update to "bitbucket-cloud",
|
|
// but the ID and other fields should remain the same.
|
|
name: "BitbucketLegacy",
|
|
config: &codersdk.ExternalAuthConfig{
|
|
Type: "bitbucket",
|
|
},
|
|
expected: codersdk.ExternalAuthConfig{
|
|
Type: string(codersdk.EnhancedExternalAuthProviderBitBucketCloud),
|
|
ID: "bitbucket", // Legacy ID remains unchanged
|
|
AuthURL: "https://bitbucket.org/site/oauth2/authorize",
|
|
TokenURL: "https://bitbucket.org/site/oauth2/access_token",
|
|
ValidateURL: "https://api.bitbucket.org/2.0/user",
|
|
DisplayName: "BitBucket",
|
|
DisplayIcon: "/icon/bitbucket.svg",
|
|
Regex: `^(https?://)?bitbucket\.org(/.*)?$`,
|
|
Scopes: []string{"account", "repository:write"},
|
|
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
applyDefaultsToConfig(tt.config)
|
|
require.Equal(t, tt.expected, *tt.config)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUntyped(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input codersdk.ExternalAuthConfig
|
|
expected codersdk.ExternalAuthConfig
|
|
}{
|
|
{
|
|
// Unknown Type uses S256 by default.
|
|
name: "RandomValues",
|
|
input: codersdk.ExternalAuthConfig{
|
|
Type: "unknown",
|
|
AuthURL: "https://auth.com/auth",
|
|
ValidateURL: "https://validate.com/validate",
|
|
TokenURL: "https://token.com/token",
|
|
RevokeURL: "https://token.com/revoke",
|
|
Regex: "random",
|
|
},
|
|
expected: codersdk.ExternalAuthConfig{
|
|
ID: "unknown",
|
|
Type: "unknown",
|
|
DisplayName: "unknown",
|
|
DisplayIcon: "/emojis/1f511.png",
|
|
AuthURL: "https://auth.com/auth",
|
|
ValidateURL: "https://validate.com/validate",
|
|
TokenURL: "https://token.com/token",
|
|
RevokeURL: "https://token.com/revoke",
|
|
Regex: `random`,
|
|
CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodSha256)},
|
|
},
|
|
},
|
|
}
|
|
for _, c := range tests {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
applyDefaultsToConfig(&c.input)
|
|
require.Equal(t, c.input, c.expected)
|
|
})
|
|
}
|
|
}
|