Files
coder/coderd/externalauth/externalauth_internal_test.go
T
J. Scott Miller 66b065323b feat: log rate-limited external auth token validation (#26754)
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.
2026-08-10 14:43:16 -05:00

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)
})
}
}