Skip to content

Give token refresh its own rate limit - #136

Merged
aaronbrethorst merged 2 commits into
OneBusAway:mainfrom
diveshpatil9104:fix/refresh-rate-limit
Oct 11, 2026
Merged

aaronbrethorst merged 2 commits into
OneBusAway:mainfrom
diveshpatil9104:fix/refresh-rate-limit

Conversation

@diveshpatil9104

@diveshpatil9104 diveshpatil9104 commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

/api/v1/auth/refresh shared login's per-IP limiter, 10 attempts a minute. That is harmless while access tokens last 24h, but not at 15m, which is where ACCESS_TOKEN_TTL goes once the Android app refreshes on 401. Drivers who log in at the same pull-out get tokens that expire at the same moment, so a depot's drivers would all refresh within seconds of each other, and they usually share one public IP (depot WiFi or carrier NAT). With 50 drivers that is 50 refreshes in one window against a budget of 10, and because the budget was shared, logins from that depot would be blocked for the same minute.

The old comments called sharing deliberate, so that an address gets one allowance across both endpoints instead of one each. That defends against credential guessing, and refresh tokens can't be guessed: they are 32 random bytes, 256 bits. A separate refresh budget gives an attacker nothing to use against login, and login's budget is unchanged. What a refresh limit actually guards against is flooding, and that needs a generous cap, not login's tight one.

RefreshRateLimiter follows RegistrationRateLimiter: one per-IP map, built on allowInWindow, fails closed at capacity, and a cleanup goroutine that main stops on shutdown. That makes three limiters with the same shape. Extracting a shared per-IP limiter would make a good follow-up, but doing it here would also refactor the rider code.

refreshIPLimit is 60 a minute, which covers a 50-driver depot's burst with about 20% left for retries and still caps a flood from one IP. A bigger depot behind one IP would still hit it. It's a constant like loginIPLimit and registrationIPLimit, and an env var can come when an agency needs one. Jittering token expiry would spread the burst out at the source, but that changes how tokens are issued, so I left it out.

TestRefresh_DoesNotSpendLoginBudget goes through the real newMux. It uses up an IP's refresh budget, then logs in from the same IP and expects a 200. Wiring loginLimiter back into the refresh route makes it fail with a 429, and leaving the refresh route unlimited fails its precondition. I deleted the test that pinned the shared budget, since it asserted the behavior this PR reverses.

newMux and newHandler gained a parameter, so 15 test call sites changed, three of them in map_handlers_test.go from #138. go build skips _test.go files and didn't catch them; go vet did. The second commit deletes LoginRateLimiter.AllowIP, which nothing calls anymore.

#135 landed the other half of #118 (revoking tokens on replay), so this PR completes the issue. It's rebased onto main, keeping #135's handler doc comment and 500 response text.

go build, vet, fmt and test are clean, and with DATABASE_URL set the store and refresh tests ran (136 passed, 0 skipped).

Fixes #118

Summary by CodeRabbit

  • New Features

    • Refresh requests have a separate per-IP limit of 60 requests per minute, independent of the login limit.
  • Documentation

    • Updated API documentation to describe the separate refresh request allowance.

@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: a728670e-0a65-45bd-abf7-39cc563a3648
📥 Commits

Reviewing files that changed from the base of the PR and between 5693c47 and 0310eec.

📒 Files selected for processing (9)
  • README.md
  • auth_refresh.go
  • auth_refresh_test.go
  • handler_composition_test.go
  • main.go
  • map_handlers_test.go
  • openapi.yaml
  • rider_wiring_test.go
  • route_wiring_test.go

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The refresh endpoint now has a dedicated per-IP limit of 60 requests per minute, separate from the login limit. The limiter is wired into the refresh route and covered by limiter and handler tests.

Changes

Refresh rate limiting

Layer / File(s) Summary
Refresh limiter and admission rules
ratelimit_refresh.go, ratelimit_refresh_test.go, ratelimit_login.go, ratelimit_login_test.go
A dedicated limiter tracks refresh requests by IP and applies a 60-request window. Tests cover the allowance, IP isolation, capacity handling, and shutdown. The login limiter's AllowIP method and its tests are removed.
Refresh route wiring and validation
auth_refresh.go, main.go, auth_refresh_test.go, handler_composition_test.go, rider_wiring_test.go, route_wiring_test.go, map_handlers_test.go, README.md, openapi.yaml
The refresh handler and route use the dedicated limiter; login continues to use the login limiter. Tests verify that exhausting the refresh allowance does not prevent login. The README and OpenAPI description document separate budgets.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant RefreshRoute
  participant handleRefreshToken
  participant RefreshRateLimiter
  Client->>RefreshRoute: POST /api/v1/auth/refresh
  RefreshRoute->>handleRefreshToken: dispatch request
  handleRefreshToken->>RefreshRateLimiter: Allow(ip)
Loading

Suggested reviewers: aaronbrethorst

Merge Risk: ⚪ Minimal · up to 0310e

Refresh requests have their own rate-limit budget without consuming the login budget. No merge-blocking issue is established by the supplied evidence.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 0310e

The change preserves login protections and refresh-token validation while preventing refresh bursts from exhausting login allowances. No introduced authentication bypass was identified, but deployment-wide capacity for the higher refresh allowance remains unverified.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — An unauthenticated caller with a syntactically valid request can now obtain up to 60 refresh-token lookup attempts per identified IP per window, rather than drawing from the shared allowance of 10. Callers behind the same NAT still share refresh admission. The higher allowance increases bounded database and logging exposure, but does not grant credential issuance or user-wide revocation authority without a recognized token.

Trust Boundaries and Controls

  • observed — The IP-identity boundary is unchanged. By default, admission uses the direct connection address. When proxy-header trust is enabled, it accepts the rightmost X-Forwarded-For value, relying on the existing requirement that deployment ingress overwrite those headers. The PR changes neither that trust switch nor its extraction logic; deployment enforcement was not verified.

Resilience and Maintainability Implications

  • observed — The unchanged rotation flow preserves single-use consumption under concurrent refreshes. Transaction errors trigger rollback handling; a losing concurrent consume attempts user-wide refresh-token revocation. Failed revocation returns 500 so retry can attempt recovery again. A committed rotation followed by signing failure or response loss does not reinstate the consumed token and may require reauthentication.

Hardening Proposals

  • proposed — Validate the 60-request allowance against database capacity and expected replica count, and monitor refresh denials and tracking-capacity saturation. Consider deployment-wide admission only if aggregate flood requirements exceed the existing process-local model; its absence is not an observed PR vulnerability.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.62% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 29 functions across 10 files. (2 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Issue #118 requires replay revocation and a separate refresh IP budget. At the reviewed head, handleRefreshToken calls DeleteRefreshTokensForUser for reused and concurrently consumed tokens. `Test…
Out of Scope Changes check ✅ Passed The limiter, handler and mux wiring, shutdown handling, tests, and API documentation support issue #118's separate refresh-budget objective. The removed LoginRateLimiter.AllowIP method and its tests…
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding a separate rate limit for token refresh.
Full details: Docstring Coverage

Explanation

Docstring coverage is 58.62% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 29 functions across 10 files. (2 skipped: 2 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@aaronbrethorst

Copy link
Copy Markdown
Member

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

@aaronbrethorst

Copy link
Copy Markdown
Member

Thanks, Divesh. This is good on the merits. A separate refresh budget keyed on the same clientIP leaves login's brute-force protection untouched, and the end-to-end mux test proves the two budgets are independent.

Now that #135 has landed on main, this branch conflicts in the handleRefreshToken doc comment and the README's response-code list. When you rebase:

Once it's mergeable it's good to go. No re-review needed unless the resolution changes behavior.

@aaronbrethorst

Copy link
Copy Markdown
Member

Code review (re-review)

Earlier blockers:

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

@aaronbrethorst aaronbrethorst left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Giving refresh its own per-IP budget is the right call. A refresh token can't be guessed, so this costs login nothing, and the new mux test proves a depot that uses up its refresh budget can still log in. Thanks for carrying #135's docs through the rebase.

@aaronbrethorst
aaronbrethorst merged commit 2ce4bb7 into OneBusAway:main Oct 11, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Revoke a user's refresh tokens when a used one is replayed

2 participants