fix(auth): send refresh token cookie to /api/v2 refresh endpoint - #3651
Conversation
Preview DeploymentPreview deployments for this PR are available at:
The preview environment will start automatically on first visit. Subsequent pushes to this PR will update the Run locally with Dockerdocker pull ghcr.io/go-vikunja/vikunja:pr-3651
docker run -p 3456:3456 ghcr.io/go-vikunja/vikunja:pr-3651Last updated for commit 4b6dfd4 |
tink-bot
left a comment
There was a problem hiding this comment.
🤖 PR Swarm — automated review, round 1. See inline comments.
| func registerAPIRoutesV2(e *echo.Echo, a *echo.Group, wsRateLimit echo.MiddlewareFunc) { | ||
| a.Use(noStoreCacheControl()) | ||
| a.Use(SetupTokenMiddleware()) | ||
| a.Use(rateLimitV2TokenRefresh()) |
There was a problem hiding this comment.
[security] 🟠 HIGH — ❓ needs your input
Pre-existing, but this PR is the first to route production traffic at the v2 auth surface: none of the public v2 auth routes (/api/v2/login, /register, /user/password/token, /user/password/reset, /shares/:share/auth, /oauth/token) have the unconditional per-IP floor that v1 applies via unauthRateLimit() (10/min/IP). /api/v2/login is an unthrottled password oracle on a default install. This round only fixed the refresh path to stay in scope. Should the same path-scoped pattern be extended to all v2 unauthenticated paths in this PR, or as a follow-up?
There was a problem hiding this comment.
do a follow up PR that adds the same rate limit pattern for unauthenticated v2 routes as v1
There was a problem hiding this comment.
On it — building the follow-up now on feat-v2-unauth-rate-limit: unauthRateLimit() path-scoped onto /api/v2/register, /user/password/token, /user/password/reset, /user/confirm, /login, /auth/openid/:provider/callback, /shares/:share/auth, plus the refresh limiter on /api/v2/oauth/token to match v1's tr group. It also folds in one shared unauthRateLimit() instance — v1 currently builds it twice (ws upgrade and the ur group), so its own budget is split the same way the refresh limiter's was.
Stacking it on this branch rather than main, since it reuses pathScoped from here; I'll retarget it to main once this merges. PR link to follow.
There was a problem hiding this comment.
Follow-up is up: #3663 (stacked on this branch, retarget to main once this merges).
|
🤖 PR Swarm — automated review. Panel: bughunter · security · conventions · design · verifier — round 3 (final) @ 6cd17e8 (+218/−114 this round, PR +438/−114) Verdict: ✅ APPROVEThree rounds, each reviewing the previous round's fixes. Round 3 caught a regression round 2 introduced and paid down the design debt behind it; nothing actionable is left. One question below is yours to answer, not a blocker. ❓ Needs your input (1)
✅ Fixed this round (7)
💤 Resolved as nit (5)
🔎 Runtime verificationLocal build (real frontend embedded), fresh user,
Reviewer summaries
Previous rounds (2)round 1 @ 6de67ee — 🚫 BLOCKED: frontend unit suite red (missing round 2 @ 22f0a85 — |
tink-bot
left a comment
There was a problem hiding this comment.
🤖 PR Swarm — automated review, round 2. See inline comments.
| } | ||
| if (loggedOutSinceStart()) { | ||
| return | ||
| } |
There was a problem hiding this comment.
[design] 🟡 MEDIUM — ❓ needs your input
The fallback fires on every 401, and v2 returns the same 401 for "no cookie" (the migration case) and "token expired/replayed" (the everyday case). So a genuinely dead session costs two refresh POSTs — four with the interceptor's own 1s retry — permanently, not just during the upgrade window. Options: (a) leave as is and delete the fallback next release; (b) give the missing-cookie branch a distinct error code and fall back only on that; (c) drop the fallback and instead ship the backend cookie change one release ahead of the frontend switch. I went with (a) plus the broadened condition above, since it self-heals in a single release — but (c) is the zero-extra-code path if you'd rather stage it.
tink-bot
left a comment
There was a problem hiding this comment.
🤖 PR Swarm — automated review, round 3 (final). See inline comments.
The cookie was path-scoped to /api/v1/user/token/refresh only, so browsers never sent it to the v2 endpoint and every v2 refresh returned 401. One cookie per API version path now, with the route literals shared as consts.
It returned *echo.HTTPError from a transport-agnostic module, which v2 had to shim back into a huma error. Domain errors render on both APIs and let callers tell the failure modes apart. address pr-swarm finding
Only an expired session or a disabled account invalidates the cookie. A transient failure must not clear it, and neither must a token rotated away by a concurrent refresh: that would delete the cookie the winning refresh just set.
v1 refresh sits behind tokenRefreshRateLimit, a per-IP floor that applies even with the global limiter off. The v2 endpoint had no floor at all, and the frontend now uses it. address pr-swarm finding
address pr-swarm finding: the admin gate and the refresh limiter hand-rolled the same shape. Matching now uses c.Path() like the other route lookups.
Browsers logged in before this release only hold the refresh cookie at the v1 path, and some deployments cannot reach v2 at all. One v1 refresh re-seeds both cookies, so sessions survive the upgrade. Drop the fallback once pre-v2 clients have cycled out.
The interceptor's retry sent a second request into the same exhausted window, which the limiter counts and which extends the block. address pr-swarm finding
address pr-swarm finding: spy still matched the v1 URL
address pr-swarm finding: the pathScoped refactor routes both admin gate halves, but only the instance-admin half was covered on v2.
6cd17e8 to
4b6dfd4
Compare

Refresh cookie path-scoped to
/api/v1/user/token/refreshonly. Browser never sent it to/api/v2/user/token/refresh, so v2 refresh always 401. Now one cookie per API version path. Frontend switched to v2 refresh.How to verify
vikunja_refresh_tokencookies, one with path/api/v1/user/token/refreshand one with/api/v2/user/token/refresh.tokenfrom localStorage and reload).POST /api/v2/user/token/refreshrequest returns200with a new token and the user stays logged in.Before this PR: the v2 request returned
401 No refresh token provided.