19 KiB
id, title, status, wave, depends_on, plan, spec
| id | title | status | wave | depends_on | plan | spec | |
|---|---|---|---|---|---|---|---|
| 02-middleware-wiring | Wire ClientIP through the auth middleware | done | 2 |
|
plan.md | ../../specs/auth/requirements/session-ip-refresh.md |
Task 02: Wire ClientIP through the auth middleware
Acceptance
httpmw.ResolveRequestpassesc.ClientIP()to the newsvc.ResolveSessionToken(ctx, cookie, ip)signature — the only production call site.- The stale
ResolveRequestdoc comment (httpmw/middleware.go:57-58, "Shared with the WS gateway's upgrade-time check") is corrected — no WS caller ofResolveRequestexists; the gateway consumes the middleware-resolved identity and PATs viaAuthPolicy.ResolveToken. - A full-chain integration test proves the observable contract with four
sub-cases: (a) a session aged past the touch interval with stored IP
1.1.1.1, requested through the real gin middleware with the session cookie andRemoteAddr2.2.2.2:1234(no forwarded headers, trusted proxies cleared), ends withsvc.ListSessionsreporting2.2.2.2AND a real-routeGET /api/v1/auth/sessionsread-back (viaauthhttpapi.RegisterRoutes) assertingrec.Code == http.StatusOKFIRST, then that the JSONipfield is2.2.2.2; (b) with trusted proxies10.0.0.0/8,RemoteAddr10.0.0.5:1234,X-Forwarded-For: 2.2.2.2, ANDX-Real-IP: 9.9.9.9, the stored IP is2.2.2.2— discriminatingc.ClientIP()(with trusted-proxy resolution and XFF-over-X-Real-IP precedence) from a RemoteAddr read (stores10.0.0.5), an X-Real-IP read (stores9.9.9.9), and header-preference-order bugs; (c) on the DEFAULT router (proxies cleared),RemoteAddr10.0.0.5:1234withX-Forwarded-For: 2.2.2.2, the stored IP is10.0.0.5— discriminatingc.ClientIP()(which ignores the untrusted header) from a rawX-Forwarded-Forread, which would store2.2.2.2. Together (b)+(c) pin the exact trusted-proxy semantics ofClientIP(): trusted peer → header, untrusted peer → RemoteAddr. (d) a DEFERRED-path request (GET /ws; the unregistered route 404s — the middleware never 401s a deferred path and this router registers no gateway route, so the status is NOT a resolution proof; thesvc.ListSessionsIP assertion below is the real discriminator, since only a successful resolve touches the session row — note the PRODUCTION gateway'srequireConnectionAuthdoes 401 unresolved deferred requests, so this scoped claim is test-router-only) with the cookie andRemoteAddr2.2.2.2:1234still refreshes —ResolveRequestruns before theisDeferredPathcheck, so cookie-authenticated deferred requests touch; this closes the skip-resolve-for-deferred-paths regression, the only gap in the planned discrimination matrix. - The trusted-proxies login-time suite
TestLoginSessionIP*inhttpapi/handlers_test.gostays green deterministically: itsloginSessionIPread-back GET re-applies the login request's FULL transport mutator closure (RemoteAddr +X-Forwarded-For+X-Real-IP), so the new touch-based refresh is idempotent on that read in all six cases and the suite gains no time-based assertion. Today's helper passes NO transport to the read-back GET (handlers_test.go:135), so the httptest default RemoteAddr192.0.2.1:1234would overwrite the asserted IP in ALL SIX cases on a >60s gap — the full closure covers every case. Reusing only the RemoteAddr would STILL leave the three forwarded-header cases (UsesForwardedFor,UsesXRealIP,MalformedXFFFallsBackToXRealIP) flaky: their stored IP is the header value203.0.113.7, and a GET carrying only the RemoteAddr against the trusted fixture resolves10.0.0.5— a >60s gap would overwrite the asserted IP. - The full
internal/authtest suite passes with the middleware wired.
Files likely touched
apps/backend/internal/auth/httpmw/middleware.go— passc.ClientIP()inResolveRequest.apps/backend/internal/auth/httpmw/middleware_test.go—newTestServicealso returns the auth store (mechanical update of existing callers);newTestRoutertakes a variadic trusted-proxy list and clears them by default (SetTrustedProxies(nil), mirroring production); newTestSessionIPRefreshedThroughMiddleware(sub-cases a-d above).apps/backend/internal/auth/httpapi/handlers_test.go— theloginSessionIPhelper'sGET /api/v1/auth/sessionsre-applies the login request's FULL transport mutator closure (RemoteAddr +X-Forwarded-For+X-Real-IP) for an idempotent read-back touch. RemoteAddr-only would reintroduce the >60s flake in the three forwarded-header cases (TestLoginSessionIPTrustedPeerUsesForwardedFor,TestLoginSessionIPUsesXRealIP,TestLoginSessionIPMalformedXFFFallsBackToXRealIP); with NO transport at all today (default192.0.2.1:1234) all six cases flake — the full closure fixes every case.
TDD sequence
- RED: extend the httpmw harness —
newTestServicereturns the auth store too;newTestRouteraccepts a variadic trusted-proxy list (no args →nil, mirroring production; this flip from gin's trust-all default is behavior-preserving for the existing callers because none of the current tests sendsX-Forwarded-For/X-Real-IP— verify that stays true when adding headers to an existing test) — and addTestSessionIPRefreshedThroughMiddlewarewith four sub-cases. Build the service withnewTestService(t, true)(auth ENABLED):falseputs the middleware inModeDisabled, which short-circuits toSyntheticIdentityBEFOREResolveRequestruns (httpmw/middleware.go) — the aged session is never resolved, every sub-case fails RED and STAYS RED after the correctc.ClientIP()wiring, a permanently-red test masquerading as a wiring defect. (The "do NOT call setupAdmin" note below only works because auth is enabled andResolveRequestactually runs.) Each sub-case uses FRESH aged session created viaauth.GenerateSessionToken()+authstore.CreateSession(the import alias used bymiddleware_test.go— the package ishttpmw, notauth):CreatedAt: now,LastSeenAtat least 2 minutes in the past (the 1-minutesessionTouchIntervalis unexported in packageauth, so the recipe cannot name it from this package),ExpiresAt: now.Add(24*time.Hour)— a narrow future expiry (e.g. 1m; a 1h expiry would only hazard a >1h stall) would let a >60s creation-to-probe gap delete the session inResolveSessionToken(service_credentials.go:85-88, delete before touch) and fail RED for the wrong reason, the same failure class task-01's 24h futures pin (aged and within-interval cases); the fixture'sSessionTTLHours = 720makes the value consistent —UserID: userstore.DefaultUserID(active row seeded byuserstore.Provide—internal/user/store/sqlite.go:99-120,ensureDefaultUserfunc at:99, INSERT at:108-110),IP: "1.1.1.1"(setCreatedAt: now— see above — for well-formed rows). Do NOT callsetupAdminfor this test: the seeded activeDefaultUserIDrow is sufficient, andsetupAdminwould mint a SECONDDefaultUserIDsession that any single-row assumption would trip on (the TokenHash selection below handles it). Each sub-case runs on a FRESH harness — own service, router, and aged session; do not share one fixture across sub-cases. With a fresh fixtureListSessionscontains only the created session; still assert byTokenHash(see below), never bylen(...) == 1or index. Everysvc.ListSessionsassertion MUST select the row byTokenHash(or the session ID captured at creation), never by list index — the rule holds even though each sub-case has a single fresh session, so a future fixture extension cannot silently break the assertions.- Sub-case a: default router (trusted proxies cleared), cookie +
req.RemoteAddr = "2.2.2.2:1234", noX-Forwarded-For→ assertsvc.ListSessionsreports2.2.2.2. Additionally, register the REAL auth routes on the router (authhttpapi.RegisterRoutes(router, svc, log)wherelog, _ := logger.NewFromZap(zap.NewNop())) — importable from this internalhttpmwtest without an import cycle, becausehttpapi's non-test files never importhttpmw; pass a real Nop-backed logger rather than nil so a future logging handler cannot nil-panic this integration test — andGET /api/v1/auth/sessionswith the same cookie and transport closure, assertingrec.Code == http.StatusOKFIRST (theexpires=nowbug surfaces as a 401 here — assert it loudly at the status line, not via a failed JSON decode), then that the JSON row withcurrent: truecarriesip: 2.2.2.2(production{"sessions":[{...}]}wire shape; select bycurrentflag, matchingloginSessionIP's selection inhttpapi/handlers_test.go— robust if the fixture ever gains rows). EXECUTION ORDER is part of the contract: probe request FIRST (assert its 200), THENsvc.ListSessions, THEN the read-back. The read-back must run AFTER the probe's touch so its resolve observes a buggyexpires=now— the probe writesexpires=now1and the read-back's expiry check 401s at its JSON assertion. In the reversed order the same bug fails the PROBE's 200 assert instead (the read-back's touch writesexpires=now1, the probe's resolve sees it expired and 401s) — red either way, but the pin keeps the discrimination in the read-back where the JSON assertion lives. This real-route read-back is ALSO the sole service-levelexpires_at-arg discrimination: a bug writingexpires = nowinstead ofnow + SessionTTL()surfaces here as delete-on-expiry (the read-back's resolve seesExpiresAt.Before(now)and returns 401) — it must NOT be removed as redundant with theListSessionsassertion. Using the real handler instead of a hand-mirrored route makes scenario 1's HTTP half genuinely end-to-end and immune to mirror drift (a renamed JSON tag would slip a mirror but not the real handler). - Sub-case b (trusted-peer discrimination): a router with
SetTrustedProxies([]string{"10.0.0.0/8"}), cookie +RemoteAddr10.0.0.5:1234+X-Forwarded-For: 2.2.2.2+X-Real-IP: 9.9.9.9→ assertsvc.ListSessionsreports2.2.2.2. The third header pins gin's header-PREFERENCE order (XFF wins over X-Real-IP), so a RemoteAddr-wired middleware (stores10.0.0.5) and an X-Real-IP-wired middleware (stores9.9.9.9) fail deterministically instead of by accident. Within-XFF element order is NOT pinned (a single XFF value is used, so a right-to-left parser returns the same value) — out of scope for this test. - Sub-case c (untrusted-header discrimination): the DEFAULT router —
the
SetTrustedProxies(nil)default fromnewTestRouter, NOT gin.New()'s trust-all default, which would honor the header, resolve2.2.2.2, and fail the case against correct wiring — cookie +RemoteAddr10.0.0.5:1234+X-Forwarded-For: 2.2.2.2→ assertsvc.ListSessionsreports10.0.0.5. A middleware that reads the rawX-Forwarded-Forheader without the trusted-proxy check (would store2.2.2.2) fails this case. (With a RemoteAddr fallback such a middleware would sail through both (a) and (b), so (c) is required; a pure header reader with no fallback would already fail (a), where no header is set — either variant is caught.) MirrorsTestLoginSessionIPUntrustedPeerIgnoresForwardedFor(httpapi/handlers_test.go). Onlyc.ClientIP()passes (b)+(c), pinning the spec's "sameClientIP()value that records the IP at login, includingKANDEV_TRUSTED_PROXIESresolution". - Sub-case d (deferred-path refresh): the DEFAULT router, a fresh aged
session, cookie +
req.RemoteAddr = "2.2.2.2:1234", requestGET /ws(or anyisDeferredPathroute) → assertrec.Code == http.StatusNotFound(the middleware never 401s a deferred path and this router registers no gateway route, so an unresolved request also 404s through the deferral — the status is NOT a resolution proof; production's gatewayrequireConnectionAuthwould 401 here, so this scoped claim is test-router-only), then assertsvc.ListSessionsreports2.2.2.2— the IP assertion is the REAL discriminator, since only a successful resolve touches the session row.ResolveRequestruns before the deferred-path check, so a cookie-authenticated deferred request must touch; a buggy skip-resolve-for-deferred-paths optimization fails this case (the only planned-test gap it would otherwise slip through). RED: the middleware passes""(wired mechanically in Task 01, which updated the call to the three-arg signature), so the empty IP never refreshes and the stored IP stays1.1.1.1in all four sub-cases — assertion-level failures (got 1.1.1.1; want 2.2.2.2 / 2.2.2.2 / 10.0.0.5 / 2.2.2.2for (a)/(b)/(c)/(d)).
- Sub-case a: default router (trusted proxies cleared), cookie +
- Implement:
svc.ResolveSessionToken(ctx, cookie, c.ClientIP())and correct the staleResolveRequestdoc comment (middleware.go:57-58). - Isolation hardening (keeps the landed trusted-proxies suite
deterministic): make the
loginSessionIPhelper inhttpapi/handlers_test.gocapture the login request's FULL transport mutator closure (RemoteAddr +X-Forwarded-For+X-Real-IP) and re-apply it to theGET /api/v1/auth/sessionsread-back. Reusing only the RemoteAddr is insufficient: inTestLoginSessionIPTrustedPeerUsesForwardedFor,TestLoginSessionIPUsesXRealIP, andTestLoginSessionIPMalformedXFFFallsBackToXRealIPthe stored IP comes from a forwarded header (203.0.113.7), and a GET carrying only the RemoteAddr against the trusted fixture resolves10.0.0.5— a >60s gap would overwrite the asserted IP, the exact flake this hardening exists to remove. Re-applying the whole closure makes the touch idempotent in all six cases. - GREEN: the integration test passes; run the trusted-proxies suite and the full auth suite (verification block below).
Verification
cd apps/backend && go test ./internal/auth/httpmw -run '^TestSessionIPRefreshedThroughMiddleware$' -count=1 && go test ./internal/auth/httpapi -run '^TestLoginSessionIP' -count=1 && go test ./internal/auth/... -count=1
Dependencies
Task 01 (01-store-service-refresh) — this task uses the new
ResolveSessionToken(ctx, token, ip) signature and the middleware already
compiles against it with a "" argument.
Parallelism
sequential.
Inputs
- Spec Scenarios 1 and What (refresh on the touch path, keyed on the
request's
ClientIP(), includingKANDEV_TRUSTED_PROXIESresolution). - Plan "Backend → Middleware" section.
apps/backend/internal/auth/httpmw/middleware_test.go:21-81(newTestService,newTestRouter,doRequest— the latter already variadic over request mutators: setRemoteAddrand headers there); the probe route at/api/v1/probeechoes the resolved identity and returns 200 for a valid session. The auth store import is aliasedauthstore; thesessionTouchIntervalconstant is NOT accessible from this package.auth.GenerateSessionToken()returns(token, hash, error)— usehashforauthstore.CreateSessionandtokenas the cookie value.- For the real-route read-back, import
github.com/kandev/kandev/internal/common/logger(aliasedlogger, collision-free inmiddleware_test.go) andgo.uber.org/zap(logger.NewFromZap(zap.NewNop())ininternal/common/logger). apps/backend/internal/auth/httpapi/handlers_test.go—loginSessionIP(helper at:112; the read-back GET to harden is:135,current-flag selection ~:150) andapiClient.do's variadic request mutator (added by the trusted-proxies work) for the idempotent read-back change.
Risks
httptest.NewRequestdefaultsRemoteAddrto192.0.2.1:1234; sub-case a must override it explicitly and must NOT set forwarded headers; sub-case b must set both the trusted list and the forwarded header, or the discrimination assertion is meaningless.newTestServicecurrently returns*auth.Service; changing it to also return the store touches every existing test in the file — mechanical, keep assertions unchanged.- Aged test sessions need a future
ExpiresAtand the seeded active default-user row, or resolution fails before the touch path runs. - Without the step-3
loginSessionIPhardening (full transport mutator, not just RemoteAddr), the landedTestLoginSessionIP*suite becomes time-sensitive under the new refresh behavior; the hardening is part of this task, not optional.
Output contract
Report the RED failures (all four sub-cases), GREEN result, exact files changed,
remaining risks, and task/plan status sync. Record every exact command and
outcome in ## Results.
Results
RED (TDD step 1): go test ./internal/auth/httpmw -run '^TestSessionIPRefreshedThroughMiddleware$' -count=1 → all 4 sub-cases fail at the assertion level with the mechanical "" from Task 01:
cleared_proxies_uses_RemoteAddr:stored IP = "1.1.1.1", want 2.2.2.2trusted_peer_honors_X-Forwarded-For_over_X-Real-IP:stored IP = "1.1.1.1", want 2.2.2.2untrusted_peer_ignores_X-Forwarded-For:stored IP = "1.1.1.1", want 10.0.0.5deferred_path_request_still_refreshes:stored IP = "1.1.1.1", want 2.2.2.2
GREEN (TDD step 2): svc.ResolveSessionToken(ctx, cookie, c.ClientIP()) wired + stale doc comment corrected → the same command passes all 4 sub-cases.
Isolation hardening (TDD step 3): loginSessionIP in httpapi/handlers_test.go
re-applies the login request's full transport closure (RemoteAddr +
X-Forwarded-For + X-Real-IP) to the GET /api/v1/auth/sessions read-back.
Verification:
cd apps/backend && go test ./internal/auth/httpmw -run '^TestSessionIPRefreshedThroughMiddleware$' -count=1→ PASS (4 sub-cases).cd apps/backend && go test ./internal/auth/httpapi -run '^TestLoginSessionIP' -count=1→ PASS (6 tests).cd apps/backend && go test ./internal/auth/... -count=1→ PASS.go test -race -count=1 ./internal/auth/...→ PASS (race clean).
Files changed: httpmw/middleware.go (c.ClientIP() + doc-comment fix),
httpmw/middleware_test.go (newTestService returns the store, newTestRouter
variadic trusted proxies clearing gin's trust-all default, new
TestSessionIPRefreshedThroughMiddleware), httpapi/handlers_test.go
(loginSessionIP idempotent read-back transport).
Remaining risks: none. The full-chain refresh is covered by the four sub-cases and the landed trusted-proxies suite stays deterministic under the new touch behavior.