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

Skip to content

feat: log rate-limited external auth token validation - #26754

Merged
jscottmiller merged 4 commits into
mainfrom
scott/plat-153-rate-limited-token-validation-is-invisible-to-operators
Aug 10, 2026
Merged

feat: log rate-limited external auth token validation#26754
jscottmiller merged 4 commits into
mainfrom
scott/plat-153-rate-limited-token-validation-is-invisible-to-operators

Conversation

@jscottmiller

@jscottmiller jscottmiller commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

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.

Manual testing

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.

🤖 Generated with the help of Coder Agents on behalf of @jscottmiller.

@linear-code

linear-code Bot commented Jun 26, 2026

Copy link
Copy Markdown

PLAT-153

@jscottmiller jscottmiller changed the title feat: log and meter rate-limited external auth token validation feat: log rate-limited external auth token validation Jun 29, 2026
@jscottmiller
jscottmiller force-pushed the scott/plat-153-rate-limited-token-validation-is-invisible-to-operators branch 4 times, most recently from c5b1cd3 to b2c420f Compare June 29, 2026 22:44
@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Docs preview

Check off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here.

@jscottmiller
jscottmiller force-pushed the scott/plat-153-rate-limited-token-validation-is-invisible-to-operators branch 3 times, most recently from 17610a0 to 8c94582 Compare June 30, 2026 20:03
@jscottmiller

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Chat: Review posted | View chat
Requested: 2026-06-30 20:24 UTC by @jscottmiller
Spend: $24.34 / $100.00

Review history
  • R1 (2026-06-30): 16 reviewers, 2 Nit, 1 Note, 3 P2, 1 P3, COMMENT. Review

deep-review v0.9.0 | Round 1 | 14a6104..8c94582

Last posted: Round 1, 7 findings (3 P2, 1 P3, 2 Nit, 1 Note), COMMENT. Review

Finding inventory

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P2 Open coderd/promoauth/oauth2.go:181 Commit message and PR description name wrong function/package (promoauth.IsRateLimited vs xhttp.IsGitHubRateLimited); commit also omits status_code label R1 Mafu-san P2, Leorio Nit Yes
CRF-2 P2 Open coderd/externalauth/externalauth.go:61 Logger doc says "validation outcomes" but only rate-limited outcomes are logged R1 Gon P2, Leorio P3 Yes
CRF-3 P2 Open coderd/promoauth/oauth2.go:174 NewGithub doc omits new rate-limited response counter R1 Gon P2 Yes
CRF-4 P3 Open coderd/externalauth/externalauth.go:495 IsGitHubRateLimited runs on all providers but metric interceptor only on GitHub R1 Ryosuke P3 Yes
CRF-5 Nit Open coderd/promoauth/oauth2.go:109 Help text says "GitHub api calls"; sibling says "external oauth2 providers" R1 Gon Nit, Leorio Note Yes
CRF-6 Nit Open coderd/util/xhttp/xhttp.go:7 IsGitHubRateLimited doc carries mechanism paragraph the switch structure already teaches R1 Gon P2 (downgraded) Yes
CRF-7 Nit Dropped by orchestrator (root logger has no pre-existing name; verified no redundant nesting) coderd/externalauth/externalauth.go:923 Logger .Named("externalauth") may nest redundantly R1 Ryosuke Nit No
CRF-8 Note Open coderd/externalauth/externalauth.go:541 reason field values use inconsistent framing (rate_limit_headers vs http_429) R1 Gon Note Yes
CRF-9 Note Dropped by orchestrator (redundant with individual findings CRF-2, CRF-3, CRF-6) (pattern) Comment bloat pattern: 16/20 in-scope comments restate code R1 Gon Note No
CRF-10 P2 Dropped by orchestrator (standard Go doc convention for unexported method; not misleading) coderd/externalauth/externalauth.go:537 logRateLimitedValidation doc restates function name and body R1 Gon P2 No

Round log

Round 1

Panel (16 reviewers). 3 P2, 1 P3, 2 Nit, 1 Note posted. 3 dropped. Reviewed against 14a6104..8c94582.

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@coder-agents-review coder-agents-review Bot left a comment

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.

Clean observability addition. The shared xhttp.IsGitHubRateLimited predicate prevents metric/validation divergence by construction, the test helper builds through ConvertConfig matching production wiring, and negative-path coverage is thorough. 70.8% test density on an observability-only change.

3 P2, 1 P3, 2 Nit, 1 Note. The P2s are documentation accuracy: the commit message references promoauth.IsRateLimited (the function is xhttp.IsGitHubRateLimited), the Logger field doc overstates its scope, and NewGithub's doc omits the new counter. The P3 is an observability gap: the metric interceptor only fires for GitHub providers, but ValidateToken calls IsGitHubRateLimited for all provider types, so log and metric diverge for non-GitHub configs.

As Bisky put it: "Oh, this is a nice set. Genuinely nice."


coderd/promoauth/oauth2.go:174

P2 [CRF-3] NewGithub now also wires the externalRequestRateLimited counter via its interceptor, but the doc still says "It tracks rate limits as well as just the external request counts." The new rate-limited response counter is omitted.

Suggest: // NewGithub returns an instrumented config for GitHub, tracking rate limits, rate-limited response counts, and request totals.

(Gon)

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/promoauth/oauth2.go Outdated
cfg := f.New(name, under)
cfg.interceptors = append(cfg.interceptors, func(resp *http.Response, err error) {
cfg.interceptors = append(cfg.interceptors, func(source Oauth2Source, resp *http.Response, err error) {
if xhttp.IsGitHubRateLimited(resp) {

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.

P2 [CRF-1] The commit message body says "The detection moves into promoauth as the exported IsRateLimited." The PR description says "The rate-limit detection is unified as promoauth.IsRateLimited." The actual function is xhttp.IsGitHubRateLimited in coderd/util/xhttp. The name is different, the package is different. A reader searching for promoauth.IsRateLimited will find nothing.

The commit subject also lists labels as {name,source} but the metric has {name,source,status_code}.

The behavioral claim is accurate: ValidateToken routes through the instrumented client, and the function is shared between the interceptor and validation. The description of what the code does is correct; the description of where the code lives is wrong. Commit messages are permanent records.

(Mafu-san P2, Leorio)

🤖

Comment thread coderd/externalauth/externalauth.go Outdated
Comment thread coderd/externalauth/externalauth.go Outdated
Comment thread coderd/promoauth/oauth2.go Outdated
Comment thread coderd/util/xhttp/xhttp.go Outdated
Comment thread coderd/externalauth/externalauth.go
@jscottmiller
jscottmiller force-pushed the scott/plat-153-rate-limited-token-validation-is-invisible-to-operators branch 6 times, most recently from 6ea4b66 to 6205719 Compare June 30, 2026 21:48
@jscottmiller
jscottmiller marked this pull request as ready for review July 6, 2026 15:51
@jscottmiller
jscottmiller marked this pull request as draft July 6, 2026 18:54
@jscottmiller
jscottmiller force-pushed the scott/plat-153-rate-limited-token-validation-is-invisible-to-operators branch from 6205719 to 16f1506 Compare July 6, 2026 18:55
@github-actions github-actions Bot added the stale This issue is like stale bread. label Jul 14, 2026
@jscottmiller
jscottmiller force-pushed the scott/plat-153-rate-limited-token-validation-is-invisible-to-operators branch from 16f1506 to 6f7b41f Compare July 14, 2026 16:28
@jscottmiller
jscottmiller marked this pull request as ready for review July 14, 2026 16:56
@github-actions github-actions Bot removed the stale This issue is like stale bread. label Jul 15, 2026
@github-actions github-actions Bot added the stale This issue is like stale bread. label Jul 22, 2026
@github-actions github-actions Bot closed this Jul 25, 2026
@jscottmiller jscottmiller reopened this Aug 4, 2026
@jscottmiller jscottmiller removed the stale This issue is like stale bread. label Aug 4, 2026
@BobbyHo

BobbyHo commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review

One P2 and two P3s. No blockers.

P2 — IsRateLimited widens the valid/invalid decision beyond what the description states

The PR description says "the valid/invalid decision is unchanged," but the new shared xhttp.IsRateLimited checks a header the old isRateLimited never did: the unprefixed RateLimit-Remaining (GitLab / IETF draft), in addition to GitHub's X-RateLimit-Remaining and Retry-After.

func IsRateLimited(resp *http.Response) bool {
if resp == nil {
return false
}
switch resp.StatusCode {
case http.StatusTooManyRequests:
return true
case http.StatusForbidden:
return resp.Header.Get("Retry-After") != "" ||
resp.Header.Get("X-RateLimit-Remaining") == "0" ||
resp.Header.Get("RateLimit-Remaining") == "0"
default:
return false
}
}

That feeds directly into ValidateToken's 403 branch, so a GitLab-style provider returning 403 + RateLimit-Remaining: 0 used to be treated as revoked and is now treated as optimistically valid:

// The token is no longer valid!
return false, nil, nil
case http.StatusForbidden:
// Some providers (notably GitHub) use 403 for both "token
// revoked" and "rate limit exceeded." If standard rate-limit
// headers are present, the token may still be valid and the
// validation endpoint is rejecting for a transient reason.
// Treat it as optimistically valid rather than discarding
// the token.
if xhttp.IsRateLimited(res) {
c.logRateLimitedValidation(ctx, http.StatusForbidden, "rate_limit_headers")
return true, nil, nil
}
// No rate-limit headers: genuine token revocation or
// permission error.
return false, nil, nil

Confirmed by the new test table itself (ForbiddenGitLabZeroRemaining expects true, which the old function would have returned false for). This looks intentional and reasonable, but it's a real decision change for that provider shape, not just new logging/metrics around an unchanged decision, worth either narrowing the header check back to GitHub-only, or updating the description so reviewers aren't approving something broader than stated.

P3 — NewGithub doc comment doesn't mention the new counter

// NewGithub returns a new instrumented oauth2 config for github. It tracks
// rate limits as well as just the external request counts.
//

Also flagged in the earlier coder-agents-review pass and still open. Note the counter is actually wired unconditionally in instrumentedTripper.RoundTrip (applies to plain Factory.New configs too, not just GitHub), so the more accurate fix is a note near metrics.externalRequestRateLimited or RoundTrip, not only NewGithub's comment:

func (i *instrumentedTripper) RoundTrip(r *http.Request) (*http.Response, error) {
resp, err := i.underlying.RoundTrip(r)
var statusCode int
if resp != nil {
statusCode = resp.StatusCode
}
i.c.metrics.externalRequestCount.With(prometheus.Labels{
"name": i.c.name,
"source": string(i.source),
"status_code": fmt.Sprintf("%d", statusCode),
}).Inc()
if xhttp.IsRateLimited(resp) {
i.c.metrics.externalRequestRateLimited.With(prometheus.Labels{
"name": i.c.name,
"source": string(i.source),
"status_code": fmt.Sprintf("%d", statusCode),
}).Inc()
}
// Handle any extra interceptors.
for _, interceptor := range i.c.interceptors {
interceptor(resp, err)
}
return resp, err
}

P3 — pre-1.25 wg.Add/go func/wg.Done pattern in a new test

go.mod is go 1.26.4; wg.Go(...) (1.25+) replaces this pattern:

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

🤖 Generated with Claude Code

@BobbyHo BobbyHo left a comment

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.

The overall changes LGTM. I left a couple of nit comments and shared an analysis from an AI agent. I believe those findings are also non-blocking, but they may be worth considering before merging the PR.

@@ -560,6 +537,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.

@@ -96,6 +102,16 @@ func NewFactory(registry prometheus.Registerer) *Factory {
"source",
"status_code",
}),
externalRequestRateLimited: factory.NewCounterVec(prometheus.CounterOpts{
Namespace: "coderd",

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: It might be worth defining constants for the namespace and subsystem values, such as "coderd" and "oauth2", so we don’t have to repeat these strings across multiple blocks.

@jscottmiller jscottmiller reopened this Aug 10, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 10, 2026
@coder coder unlocked this conversation Aug 10, 2026
When ValidateToken keeps a token because the validation endpoint was
rate-limited (a 403 with rate-limit headers or a 429), it returned
valid=true silently, so operators could not distinguish a
provider-confirmed token from one kept optimistically during a rate
limit.

Add a Logger to externalauth.Config and emit a Warn (with provider_id,
provider_type, status_code, reason) on the rate-limit branches, threading
a logger through ConvertConfig. The unambiguous outcomes remain visible
via coderd_oauth2_external_requests_total{source="ValidateToken",
status_code}. Observability-only: the valid/invalid decision is unchanged.
Add coderd_oauth2_external_requests_rate_limited_total{name,source,status_code},
incremented in the instrumented round tripper when a response is
rate-limited (a 429, or a 403 with rate-limit headers). Detection lives in
the shared xhttp.IsRateLimited (coderd/util/xhttp), used by both the tripper
and externalauth.ValidateToken so the metric and the validation decision
share one definition. No externalauth wiring is needed: ValidateToken
already routes through the instrumented client with source=ValidateToken.
@jscottmiller
jscottmiller force-pushed the scott/plat-153-rate-limited-token-validation-is-invisible-to-operators branch from 3294766 to 8ee8bd1 Compare August 10, 2026 16:22
@jscottmiller
jscottmiller merged commit 66b0653 into main Aug 10, 2026
32 checks passed
@jscottmiller
jscottmiller deleted the scott/plat-153-rate-limited-token-validation-is-invisible-to-operators branch August 10, 2026 19:43
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 10, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants