Files
coder/coderd/promoauth/oauth2_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

290 lines
9.1 KiB
Go

package promoauth_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"golang.org/x/sync/singleflight"
"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
"github.com/coder/coder/v2/coderd/coderdtest/promhelp"
"github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/coderd/promoauth"
"github.com/coder/coder/v2/testutil"
)
func TestInstrument(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
idp := oidctest.NewFakeIDP(t, oidctest.WithServing())
reg := prometheus.NewRegistry()
t.Cleanup(func() {
if t.Failed() {
t.Log(promhelp.RegistryDump(reg))
}
})
const id = "test"
labels := prometheus.Labels{
"name": id,
"status_code": "200",
}
const metricname = "coderd_oauth2_external_requests_total"
count := func(source string) int {
labels["source"] = source
return promhelp.CounterValue(t, reg, "coderd_oauth2_external_requests_total", labels)
}
factory := promoauth.NewFactory(reg)
cfg := externalauth.Config{
InstrumentedOAuth2Config: factory.New(id, idp.OIDCConfig(t, []string{})),
ID: "test",
ValidateURL: must[*url.URL](t)(idp.IssuerURL().Parse("/oauth2/userinfo")).String(),
RefreshGroup: new(singleflight.Group),
}
// 0 Requests before we start
require.Nil(t, promhelp.MetricValue(t, reg, metricname, labels), "no metrics at start")
noClientCtx := ctx
// This should never be done, but promoauth should not break the default client
// even if this happens. So intentionally do this to verify nothing breaks.
ctx = context.WithValue(ctx, oauth2.HTTPClient, http.DefaultClient)
// Exchange should trigger a request
code := idp.CreateAuthCode(t, "foo")
_, err := cfg.Exchange(ctx, code)
require.NoError(t, err)
require.Equal(t, count("Exchange"), 1)
// Do an exchange without a default http client as well to verify original
// transport is not broken.
code = idp.CreateAuthCode(t, "bar")
token, err := cfg.Exchange(noClientCtx, code)
require.NoError(t, err)
require.Equal(t, count("Exchange"), 2)
// Force a refresh
token.Expiry = time.Now().Add(time.Hour * -1)
src := cfg.TokenSource(ctx, token)
refreshed, err := src.Token()
require.NoError(t, err)
require.NotEqual(t, token.AccessToken, refreshed.AccessToken, "token refreshed")
require.Equal(t, count("TokenSource"), 1)
// Try a validate
valid, _, err := cfg.ValidateToken(ctx, refreshed)
require.NoError(t, err)
require.True(t, valid)
require.Equal(t, count("ValidateToken"), 1)
// Verify the default client was not broken. This check is added because we
// extend the http.DefaultTransport. If a `.Clone()` is not done, this can be
// mis-used. It is cheap to run this quick check.
snapshot := promhelp.RegistryDump(reg)
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
must[*url.URL](t)(idp.IssuerURL().Parse("/.well-known/openid-configuration")).String(), nil)
require.NoError(t, err)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
_ = resp.Body.Close()
require.NoError(t, promhelp.Compare(reg, snapshot), "http default client corrupted")
}
func TestGithubRateLimits(t *testing.T) {
t.Parallel()
now := time.Now()
cases := []struct {
Name string
NoHeaders bool
Omit []string
ExpectNoMetrics bool
Limit int
Remaining int
Used int
Reset time.Time
at time.Time
}{
{
Name: "NoHeaders",
NoHeaders: true,
ExpectNoMetrics: true,
},
{
Name: "ZeroHeaders",
ExpectNoMetrics: true,
},
{
Name: "OverLimit",
Limit: 100,
Remaining: 0,
Used: 500,
Reset: now.Add(time.Hour),
at: now,
},
{
Name: "UnderLimit",
Limit: 100,
Remaining: 0,
Used: 500,
Reset: now.Add(time.Hour),
at: now,
},
{
Name: "Partial",
Omit: []string{"x-ratelimit-remaining"},
ExpectNoMetrics: true,
Limit: 100,
Remaining: 0,
Used: 500,
Reset: now.Add(time.Hour),
at: now,
},
}
for _, c := range cases {
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
reg := prometheus.NewRegistry()
idp := oidctest.NewFakeIDP(t, oidctest.WithMiddlewares(
func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if !c.NoHeaders {
rw.Header().Set("x-ratelimit-limit", fmt.Sprintf("%d", c.Limit))
rw.Header().Set("x-ratelimit-remaining", fmt.Sprintf("%d", c.Remaining))
rw.Header().Set("x-ratelimit-used", fmt.Sprintf("%d", c.Used))
rw.Header().Set("x-ratelimit-resource", "core")
rw.Header().Set("x-ratelimit-reset", fmt.Sprintf("%d", c.Reset.Unix()))
for _, omit := range c.Omit {
rw.Header().Del(omit)
}
}
// Ignore the well known, required for setup
if !strings.Contains(r.URL.String(), ".well-known") {
// GitHub rate limits only are instrumented for unauthenticated calls. So emulate
// that here. We cannot actually use invalid credentials because the fake IDP
// will throw a test error, as it only expects things to work. And we want to use
// a real idp to emulate the right http calls to be instrumented.
rw.WriteHeader(http.StatusUnauthorized)
return
}
next.ServeHTTP(rw, r)
})
}))
factory := promoauth.NewFactory(reg)
if !c.at.IsZero() {
factory.Now = func() time.Time {
return c.at
}
}
cfg := factory.NewGithub("test", idp.OIDCConfig(t, []string{}))
// Do a single oauth2 call
ctx := testutil.Context(t, testutil.WaitShort)
ctx = context.WithValue(ctx, oauth2.HTTPClient, idp.HTTPClient(nil))
_, err := cfg.Exchange(ctx, "invalid")
require.Error(t, err, "expected unauthorized exchange")
// Verify
labels := prometheus.Labels{
"name": "test",
"resource": "core-unauthorized",
}
pass := true
if !c.ExpectNoMetrics {
pass = pass && assert.Equal(t, promhelp.GaugeValue(t, reg, "coderd_oauth2_external_requests_rate_limit", labels), c.Limit, "limit")
pass = pass && assert.Equal(t, promhelp.GaugeValue(t, reg, "coderd_oauth2_external_requests_rate_limit_remaining", labels), c.Remaining, "remaining")
pass = pass && assert.Equal(t, promhelp.GaugeValue(t, reg, "coderd_oauth2_external_requests_rate_limit_used", labels), c.Used, "used")
if !c.at.IsZero() {
until := c.Reset.Sub(c.at)
// Float accuracy is not great, so we allow a delta of 2
pass = pass && assert.InDelta(t, promhelp.GaugeValue(t, reg, "coderd_oauth2_external_requests_rate_limit_reset_in_seconds", labels), int(until.Seconds()), 2, "reset in")
}
} else {
pass = pass && assert.Nil(t, promhelp.MetricValue(t, reg, "coderd_oauth2_external_requests_rate_limit", labels), "not exists")
}
// Helpful debugging
if !pass {
t.Log(promhelp.RegistryDump(reg))
}
})
}
}
func must[V any](t *testing.T) func(v V, err error) V {
return func(v V, err error) V {
t.Helper()
require.NoError(t, err)
return v
}
}
func TestExternalRequestRateLimitedMetric(t *testing.T) {
t.Parallel()
const metricName = "coderd_oauth2_external_requests_rate_limited_total"
labels := func(status int) prometheus.Labels {
return prometheus.Labels{
"name": "test",
"source": string(promoauth.SourceValidateToken),
"status_code": fmt.Sprintf("%d", status),
}
}
reg := prometheus.NewRegistry()
cfg := promoauth.NewFactory(reg).New("test", &oauth2.Config{})
ctx := testutil.Context(t, testutil.WaitShort)
do := func(t *testing.T, status int, headers map[string]string) {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
for k, v := range headers {
w.Header().Set(k, v)
}
w.WriteHeader(status)
}))
t.Cleanup(srv.Close)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
require.NoError(t, err)
resp, err := cfg.Do(ctx, promoauth.SourceValidateToken, req)
require.NoError(t, err)
_ = resp.Body.Close()
}
// A 429 is counted as rate-limited under its status code.
do(t, http.StatusTooManyRequests, nil)
assert.Equal(t, 1, promhelp.CounterValue(t, reg, metricName, labels(http.StatusTooManyRequests)))
// A 403 carrying rate-limit headers is counted under its own status code.
do(t, http.StatusForbidden, map[string]string{"X-RateLimit-Remaining": "0"})
assert.Equal(t, 1, promhelp.CounterValue(t, reg, metricName, labels(http.StatusForbidden)))
// A 200 and a plain 403 (a genuine revocation) are not counted.
do(t, http.StatusOK, nil)
do(t, http.StatusForbidden, nil)
assert.Equal(t, 1, promhelp.CounterValue(t, reg, metricName, labels(http.StatusTooManyRequests)))
assert.Equal(t, 1, promhelp.CounterValue(t, reg, metricName, labels(http.StatusForbidden)))
assert.Nil(t, promhelp.MetricValue(t, reg, metricName, labels(http.StatusOK)))
}