Skip to content

fix(scheduledauth): retry once on unknown-kid signature failure; deterministic rotation test (closes #1381) - #1386

Merged
cristim merged 2 commits into
mainfrom
fix/oidc-rotation-test-race
Jul 16, 2026
Merged

fix(scheduledauth): retry once on unknown-kid signature failure; deterministic rotation test (closes #1381)#1386
cristim merged 2 commits into
mainfrom
fix/oidc-rotation-test-race

Conversation

@cristim

@cristim cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member

Root cause

go-oidc's RemoteKeySet uses a single goroutine ("inflight") to deduplicate concurrent JWKS fetches. The goroutine calls inflight.done(keys) to unblock all waiters, then must re-acquire an internal mutex to set inflight = nil. A second goroutine arriving in this window joins the stale inflight and receives pre-rotation keys, returning "failed to verify id token signature" for a valid post-rotation token.

Production impact: transient 401 errors during key rotation until the next successful validation re-fetches JWKS.

Test impact: TestValidate_OIDC_KeyRotation_RefreshOnUnknownKid dodged the race via goroutine-scheduling timing (RSA-sign delay between request and swap), which failed under CI load (run 29515635173 on commit 904e993).

Fix (two layers)

1. Validator resilience (validator.go)

verifyWithRotationRetry() is called from validateOIDC in place of a bare v.verifier.Verify. When go-oidc returns an error containing "failed to verify signature" it rebuilds RemoteKeySet and IDTokenVerifier from scratch (no cached inflight), then retries exactly once.

Fail-closed constraints enforced:

  • Retry only on signature errors. aud/iss/exp/nbf/sub failures return immediately without retry.
  • One rebuild per rotation: a pointer-equality check (v.verifier == ver under write lock) prevents concurrent goroutines from spawning duplicate rebuilds.
  • No sleep; the retry's fresh fetch is the synchronisation.
  • Log fires once per rotation rebuild, no PII.

2. Test determinism (validator_test.go)

TestValidate_OIDC_KeyRotation_RefreshOnUnknownKid - timing comment and goroutine-scheduling trick removed; retry handles any scheduler outcome.

TestValidate_OIDC_KeyRotation_StaleInflight (new) - directly replicates the race using a controlled JWKS server: holds the first response (keyA) until after a second validation has started, forcing it to join the stale inflight. tokA verifies; tokB fails on keyA → retry rebuilds verifier → fetches keyB → succeeds. Fails on pre-fix code, passes on post-fix.

Gate results

Gate Result
go build ./... exit 0
go vet ./internal/server/scheduledauth/... exit 0
-race -count=10 (whole package, 52 tests) 520/520
-run RefreshOnUnknownKid -race -count=50 50/50
-run StaleInflight -race -count=50 50/50
gocyclo -over 10 on touched files exit 0 (no violations)

Summary by CodeRabbit

  • Bug Fixes
    • Improved OIDC token validation reliability during signing-key rotation by retrying verification when a stale key set is detected.
    • Retry is limited to signature-verification errors only and is rate-limited to prevent excessive rebuilds.
    • Maintains fail-closed behavior for claim/issuer/audience/time validation failures.
  • Tests
    • Added regression coverage for stale-in-flight JWKS refresh behavior and retry rate-limiting/recovery after rotation intervals.

@cristim cristim added triaged Item has been triaged priority/p2 Backlog-worthy severity/medium Moderate harm urgency/now Drop other things impact/internal Team-internal only effort/s Hours type/bug Defect labels Jul 16, 2026
@cristim

cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 6 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 086ac8ac-106d-4a4c-8c50-e36df53e6568

📥 Commits

Reviewing files that changed from the base of the PR and between 176a772 and 4ed5999.

📒 Files selected for processing (2)
  • internal/server/scheduledauth/validator.go
  • internal/server/scheduledauth/validator_test.go
📝 Walkthrough

Walkthrough

OIDC validation now retries once after signature failures caused by stale in-flight JWKS data. The validator synchronizes and rate-limits verifier rebuilds, while tests cover concurrent rotation, retry guards, interval recovery, and error classification.

Changes

OIDC key-rotation retry

Layer / File(s) Summary
Verifier rotation retry
internal/server/scheduledauth/validator.go
The validator stores issuer and JWKS inputs, synchronizes verifier replacement, classifies signature errors, and performs one rate-limited rebuild-and-retry without retrying claim failures.
Key-rotation regression coverage
internal/server/scheduledauth/validator_test.go
Tests cover deterministic JWKS swapping, stale in-flight fetches, concurrent validation, rebuild rate limiting, recovery after the interval, and signature-error classification.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Validator
  participant IDTokenVerifier
  participant RemoteKeySet
  participant JWKS_Server
  Validator->>IDTokenVerifier: Verify token
  IDTokenVerifier->>RemoteKeySet: Fetch signing keys
  RemoteKeySet->>JWKS_Server: Request JWKS
  IDTokenVerifier-->>Validator: Return signature error
  Validator->>RemoteKeySet: Rebuild key set
  RemoteKeySet->>JWKS_Server: Fetch current JWKS
  Validator->>IDTokenVerifier: Retry verification once
Loading

Possibly related issues

  • LeanerCloud/CUDly issue 1381 — Addresses the same OIDC JWKS key-rotation and stale in-flight validation behavior.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: retrying unknown-kid signature failures and adding deterministic rotation tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/oidc-rotation-test-race

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/server/scheduledauth/validator_test.go`:
- Around line 529-533: Make the tokB concurrency test deterministically
synchronize with the stale-inflight window instead of relying on the
runtime.Gosched loop. Update the tokB fetch path around keysFromRemote to expose
an observable entry signal, or use an existing rebuild/generation counter, wait
for that signal before releasing the blocked request, and assert the
rotation-retry path was exercised rather than the ordinary unknown-kid refresh
path.

In `@internal/server/scheduledauth/validator.go`:
- Around line 404-429: Update the retry logic around Verify and isSignatureError
so only the terminal stale-key/signature-mismatch error triggers RemoteKeySet
rebuilding; propagate JWKS fetch, cancellation, and other verification errors
without retrying. In the v.verifier rebuild path, retain the concurrent pointer
guard and add a cooldown or generation guard to prevent sequential invalid
tokens from repeatedly creating fresh key sets and fetching JWKS.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0fc43525-4e16-4974-9c4a-57bde1c2aa63

📥 Commits

Reviewing files that changed from the base of the PR and between cfa2b1e and 172374e.

📒 Files selected for processing (2)
  • internal/server/scheduledauth/validator.go
  • internal/server/scheduledauth/validator_test.go

Comment thread internal/server/scheduledauth/validator_test.go
Comment thread internal/server/scheduledauth/validator.go
@cristim

cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

DoS fix landed (commit 176a772):

Root cause: every bad-signature token entered the rebuild path, firing a fresh JWKS GET on the retry. An unauthenticated attacker could amplify garbage tokens into ~unbounded requests against the OIDC provider's JWKS endpoint.

Fix: minRebuildInterval = 10s guarded by verMu. Inside the v.verifier == ver rebuild block, if time.Since(v.lastRebuild) < minRebuildInterval, the rebuild is skipped and the original signature error is returned immediately (fail closed, no retry, no fetch). After a real rebuild v.lastRebuild = v.now() is recorded. The pointer-equality concurrent-dedup guard is unchanged.

Fetch-count proof (TestVerifyRetry_RebuildRateLimited): server always serves keyA; two sequential tokens signed with unpublished kid-X. With the rate limit:

  • token 1: fetch#1 (lazy init) -> sig fail -> rebuild -> fetch#2 (retry) -> sig fail -> 401
  • token 2: cached keys, kid miss -> fetch#3 (go-oidc refresh-on-unknown-kid, unavoidable) -> sig fail -> rate-limited: NO rebuild, NO fetch -> 401
  • Total: exactly 3 fetches asserted. Without the limit token 2 would add fetch#4+.

Rotation still recovers (TestVerifyRetry_RotationRecoversAfterInterval): injectable clock advanced past interval triggers rebuild#2 and succeeds - no time.Sleep.

Error-string contract pinned (TestIsSignatureError_MatchesGoOIDCContract): real bad-sig token driven through verifier; asserts isSignatureError matches go-oidc v3's wording; fails CI loudly on future go-oidc bumps that rewire the sentinel.

Gate results: go build ./... EXIT:0, go vet ./... EXIT:0, -race -count=10 550 passed EXIT:0, targeted -race -count=50 250 passed EXIT:0, gocyclo -over 10 EXIT:0.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== Current PR diff summary =='
git diff --stat HEAD^ HEAD || true
git diff -- internal/server/scheduledauth/validator.go internal/server/scheduledauth/validator_test.go || true

echo '== validator.go outline =='
ast-grep outline internal/server/scheduledauth/validator.go --items all --type function,struct,method --view expanded || true

echo '== validator_test.go rotation/rate-limit test locations =='
rg -n -C 5 'VerifyRetry|KeyRotation|RebuildRateLimited|RotationRecovers|IsSignatureError|minRebuildInterval|lastRebuild|verifyWithRotationRetry' \
  internal/server/scheduledauth/validator.go internal/server/scheduledauth/validator_test.go || true

echo '== Relevant module dependency version =='
rg -n -C 2 'coreos/go-oidc|go-oidc' go.mod go.sum || true

Length of output: 24458


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== Validator construction and OIDC configuration =='
sed -n '95,185p' internal/server/scheduledauth/validator.go

echo '== Complete rotation retry implementation =='
sed -n '386,470p' internal/server/scheduledauth/validator.go

echo '== Complete stale-inflight and throttle tests =='
sed -n '459,715p' internal/server/scheduledauth/validator_test.go

echo '== Test JWKS helpers and server state =='
rg -n -C 8 'func newJWKSServer|type .*JWKS|hits|func newOIDCValidator|func signToken|func baseClaims' \
  internal/server/scheduledauth/validator_test.go

echo '== Locally cached go-oidc RemoteKeySet implementation (if available) =='
modcache="$(go env GOMODCACHE 2>/dev/null || true)"
if [ -n "$modcache" ] && [ -d "$modcache" ]; then
  fd -a 'remote_key_set\.go|remote.*key.*set.*\.go' "$modcache/github.com/coreos" 2>/dev/null | head -20 | while read -r f; do
    echo "--- $f"
    rg -n -C 6 'func \(.*RemoteKeySet.*\) VerifySignature|keysFromRemote|inflight|nextUpdate' "$f" || true
  done
else
  echo 'Go module cache unavailable.'
fi

Length of output: 25860


@cristim I found one remaining test-coverage issue:

TestValidate_OIDC_KeyRotation_StaleInflight is still scheduler-dependent. As its own comment notes, the critical tokB join of the existing RemoteKeySet inflight request is only “best-effort”: the test releases the first fetch after 20 runtime.Gosched() calls, without any signal that tokB actually joined it. If tokB starts after release, it can fetch key B directly and succeed without exercising verifyWithRotationRetry.

Please make the test assert the intended path—e.g., instrument the JWKS handler/client so the test can prove no second fetch occurs before releasing request 1, then require the retry’s post-release fetch—or avoid claiming this regression test is deterministic.

The rebuild rate-limit itself looks correctly fail-closed: within the interval it returns the original signature error before constructing a new verifier or retrying.

🐇🔍

@cristim

cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

Rebased onto main (post-#1389, which fixes the gocyclo -over 10 debt from #1207). gocyclo now 0 violations; the Lint Code + pre-commit gocyclo step should be green on this head.

@cristim

cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

Rebased onto main (post-#1364, Lint now green on main); should be fully green on this head.

@cristim

cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

cristim added 2 commits July 16, 2026 21:49
…rministic rotation test (closes #1381)

go-oidc's RemoteKeySet caches an in-flight JWKS fetch via a single goroutine.
The goroutine calls inflight.done(keys) to unblock callers, then must
re-acquire the mutex to set inflight=nil. A second goroutine arriving in this
window joins the stale inflight, receives pre-rotation keys, and returns
"failed to verify id token signature" for a valid post-rotation token.

In production this causes a transient 401 for a brief window during key
rotation. The test relied on goroutine-scheduling timing (RSA-sign delay) to
avoid the race, which failed under CI load (run 29515635173/904e9939e).

Fix (fail-closed, two layers):

1. Validator resilience: verifyWithRotationRetry() detects "failed to verify
   signature" errors from go-oidc and rebuilds the RemoteKeySet from scratch
   (no cached inflight), then retries exactly once. Non-signature failures
   (aud/iss/exp/nbf/sub) are returned immediately without retry. Only one
   rebuild fires per rotation event: a pointer-equality check prevents
   duplicate rebuilds under concurrent load.

2. Test determinism: TestValidate_OIDC_KeyRotation_RefreshOnUnknownKid loses
   the goroutine-timing comment/ordering trick; the retry handles any
   scheduling outcome. New TestValidate_OIDC_KeyRotation_StaleInflight
   directly replicates the race: a controlled JWKS server holds its first
   response (keyA) while the JWKS rotates to keyB; tokB joins the stale
   inflight, fails, and the retry fetches keyB successfully.

Gates: go build ./... (0), go vet (0), -race -count=10 (520/520),
-run RefreshOnUnknownKid -race -count=50 (50/50),
-run StaleInflight -race -count=50 (50/50), gocyclo -over 10 (0).
…ification DoS

Sequential badly-signed tokens each triggered a keyset rebuild plus an extra
outbound JWKS GET on the retry path, letting an unauthenticated attacker
amplify garbage tokens into ~unbounded requests against the OIDC provider's
JWKS endpoint.

Fix: add `lastRebuild time.Time` guarded by verMu. Inside the rebuild block,
if `time.Since(lastRebuild) < minRebuildInterval (10s)`, skip the rebuild and
return the original signature error immediately (fail closed, no retry). After
a successful rebuild, record lastRebuild = now(). The existing pointer-equality
guard still deduplicates concurrent goroutines hitting the same rotation.

10s bounds attacker-driven JWKS fetches to ~6/min while genuine key rotations
still recover within one interval (providers publish the new key before signing
with it; go-oidc's own refresh-on-unknown-kid path is unaffected).

Also remove the now-unused `v.keySet` struct field; the verifier holds its own
internal reference and nothing else read the field.

Tests added (injectable clock, no sleeps):
- TestVerifyRetry_RebuildRateLimited: 2 sequential bad-sig tokens -> exactly
  3 JWKS fetches (not 4+), both return ErrUnauthorized
- TestVerifyRetry_RotationRecoversAfterInterval: advance injected clock past
  interval, genuine rotation still recovers via rebuild#2
- TestIsSignatureError_MatchesGoOIDCContract: drives real tokens through the
  verifier and asserts isSignatureError matches go-oidc v3.18.0's wording;
  fails CI loudly if a future bump rewords the sentinel string
@cristim
cristim force-pushed the fix/oidc-rotation-test-race branch from 1efb040 to 4ed5999 Compare July 16, 2026 18:53
@cristim

cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

Rebased onto current main (head 4ed5999) after #1364 (lint-debt-clear) merged and touched validator.go.

Conflicts resolved (2 files):

  1. validator.go Validator struct - main reformatted the struct (bare fields, reordered) while my flaky test: TestValidate_OIDC_KeyRotation_RefreshOnUnknownKid fails intermittently in CI #1381 retry commit added verMu/issuer + field doc comments. Main's field set is a strict subset of mine, so I kept my superset struct (fieldalignment is disabled globally in .golangci.yml, so order is free). The DoS commit then cleanly removed the now-unused keySet field and added lastRebuild on top.

  2. validator_test.go swap-request build - a genuine dual-lint conflict. Main applied noctx (http.NewRequestWithContext) but with the strings.NewReader(string(jwksB)) byte-copy anti-pattern; my version used bytes.NewReader(jwksB) but bare http.NewRequest. Resolved by combining BOTH improvements: http.NewRequestWithContext(ctx, ..., bytes.NewReader(jwksB)) - satisfies noctx and avoids the string->[]byte copy.

Lint-gate cleanup: main's misspell linter (US locale) is now a hard gate. My new comments had 3 British spellings (behaviour x2, synchronisation); fixed to US spelling. All DoS/rate-limit logic (verifyWithRotationRetry, lastRebuild, minRebuildInterval, isSignatureError, mutex-guarded verifier swap) preserved verbatim.

Gate exit codes (final rebased head):

  • golangci-lint run ./internal/server/scheduledauth/... -> No issues found, exit 0
  • go build ./... exit 0
  • go vet ./... exit 0
  • go test -race -count=10 -> 550 passed, exit 0
  • targeted -race -count=50 -> 250 passed, exit 0
  • gocyclo -over 10 validator.go exit 0

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== Diff for reviewed files =='
git diff --unified=80 HEAD^ HEAD -- \
  internal/server/scheduledauth/validator.go \
  internal/server/scheduledauth/validator_test.go || true

echo '== Validator outline =='
ast-grep outline internal/server/scheduledauth/validator.go \
  --items all --type struct,function,method --view expanded || true

echo '== Test outline =='
ast-grep outline internal/server/scheduledauth/validator_test.go \
  --items all --type function,struct --view expanded || true

echo '== Retry implementation and synchronization-related test code =='
rg -n -C 12 \
  'verifyWithRotationRetry|isSignatureError|minRebuildInterval|lastRebuild|TestValidate_OIDC_KeyRotation_StaleInflight|runtime\.Gosched|inflight|release|fetch|hits|started|joined' \
  internal/server/scheduledauth/validator.go \
  internal/server/scheduledauth/validator_test.go || true

echo '== Relevant precise file ranges =='
sed -n '95,190p' internal/server/scheduledauth/validator.go
sed -n '380,480p' internal/server/scheduledauth/validator.go
sed -n '440,760p' internal/server/scheduledauth/validator_test.go

Length of output: 122296


@cristim I found one remaining issue: the previous stale-inflight test concern is still unresolved.

TestValidate_OIDC_KeyRotation_StaleInflight explicitly says its critical join is “best-effort” (Lines 526–530), then relies on 20 runtime.Gosched() calls (Lines 534–538). It only asserts that tokB eventually succeeds; it does not assert that tokB joined request 1, received key A, failed signature verification, and caused the retry’s fresh key-B fetch.

Consequently, if tokB is scheduled after release is closed, it can fetch key B directly and pass without exercising verifyWithRotationRetry. Please either add synchronization/observability that proves the intended stale-inflight and retry-fetch sequence, or relabel/remove the test’s deterministic-regression claim.

The rebase conflict resolution itself looks sound: the bytes.NewReader + http.NewRequestWithContext combination is retained, and the mutex-guarded rate-limit behavior remains fail-closed.

@cristim
cristim merged commit fee120d into main Jul 16, 2026
18 checks passed
@cristim
cristim deleted the fix/oidc-rotation-test-race branch July 16, 2026 19:05
@cristim

cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

Merged to main (closes #1381): fixes both the flaky rotation test AND the underlying production race - go-oidc's stale-inflight window during JWKS key rotation caused spurious signature failures (transient 401s in prod, CI flake in test). Fix: retry-once on unknown-kid signature failure by rebuilding the verifier, guarded by a 10s rebuild rate-limit (fail-closed) so bad-token spam cannot amplify JWKS fetches (Fable-caught DoS). Deterministic StaleInflight regression test + error-string contract test pinning go-oidc v3.18.0. Residual 1-fetch-per-token (go-oidc spec) tracked in #1387. This retires the #1381 flake exception - main's Unit Tests are now deterministically green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/internal Team-internal only priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/bug Defect urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant