[Bug] Fix admin refresh & persist csrf along session#1307
Conversation
WalkthroughThis PR refactors authentication to support separate user and admin sessions with session-type-aware refresh tokens. TokenManager now embeds ChangesAdmin Dashboard Authentication with Session-Type-Aware Token Refresh
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
|
Preview deployment for your docs. Learn more about Mintlify Previews.
|
Greptile SummaryThis PR fixes two root-cause bugs in the admin dashboard session flow: CSRF tokens are now derived from stable JWT payload claims (
Confidence Score: 4/5The core CSRF and session-isolation logic is sound; the main open concern from a prior review (admin /logout has no CSRF guard while its cookie uses sameSite: none) remains unaddressed in this revision. The CSRF derivation, nonce preservation across token rotation, and admin/user cookie path separation all look correct. The one unresolved issue from a previous review pass — the /logout endpoint accepting cross-site requests without any CSRF check — is still present in admin.routes.ts. Until that is addressed, a malicious page can force an admin logout at any time. backend/src/api/routes/auth/admin.routes.ts — specifically the /logout handler which clears the admin cookie without validating X-CSRF-Token. Important Files Changed
Reviews (3): Last reviewed commit: "remove endpoint test" | Re-trigger Greptile |
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
packages/dashboard/src/features/login/services/login.service.ts (1)
89-97:⚠️ Potential issue | 🟠 Major | ⚡ Quick winUse the configured API base URL for admin refresh requests.
Line 89 hardcodes
/api/auth/admin/refresh, which bypassesgetDashboardApiBaseUrl()used by the rest of the client. In non-default/proxied setups, this can break refresh and cause auth loops.Suggested fix
import { apiClient, REQUEST_TIMEOUT_MS } from '`#lib/api/client`'; +import { getDashboardApiBaseUrl } from '`#lib/config/runtime`'; import type { UserSchema } from '`@insforge/shared-schemas`'; @@ - const response = await fetch('/api/auth/admin/refresh', { + const response = await fetch( + `${getDashboardApiBaseUrl().replace(/\/$/, '')}/auth/admin/refresh`, + { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken, }, credentials: 'include', signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); + });🤖 Prompt for 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. In `@packages/dashboard/src/features/login/services/login.service.ts` around lines 89 - 97, The fetch call in login.service.ts uses a hardcoded '/api/auth/admin/refresh' which bypasses getDashboardApiBaseUrl(); update the code that builds the refresh URL to use getDashboardApiBaseUrl() (e.g., compute const base = getDashboardApiBaseUrl(); then build the refresh endpoint as base + '/api/auth/admin/refresh' or otherwise join paths to avoid double slashes) and use that URL in the fetch call (the function performing the fetch is the admin refresh request in login.service.ts). Ensure headers, credentials, and AbortSignal.timeout(REQUEST_TIMEOUT_MS) remain unchanged.backend/src/infra/security/token.manager.ts (1)
103-117:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftKeep a legacy refresh path through the 7-day token horizon.
These guards now reject every refresh token minted before this rollout, and CSRF recomputation also depends on the new claims. On deploy, any still-active session will fail its next refresh and get logged out. Please keep a compatibility path long enough to exchange legacy refresh tokens for the new shape, then remove it after the old 7-day window has expired.
Also applies to: 208-225
🤖 Prompt for 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. In `@backend/src/infra/security/token.manager.ts` around lines 103 - 117, The strict guards in verifyRefreshToken are currently rejecting pre-rollout refresh tokens; add a compatibility path that accepts legacy refresh tokens for a 7-day window: detect a legacy shape (e.g., missing csrfNonce or missing/absent sessionType or older claim layout) and allow verification if jwt.verify succeeds and the token's iat/exp indicates it was issued within the last 7 days; for legacy tokens, populate default values for csrfNonce/sessionType in the returned RefreshTokenPayload (or include a legacyRefresh flag) so callers can force rotation and skip CSRF recomputation for that flow. Apply the same compatibility changes to the corresponding logic referenced around lines 208-225 so both verification paths accept tokens from the 7-day horizon and mark them for exchange.
🤖 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 `@backend/src/api/routes/auth/admin.routes.ts`:
- Around line 144-149: The catch block currently calls
clearAdminRefreshTokenCookie(res) for all non-403 errors which forces logout on
transient failures (e.g., failures in getUserById() or token issuance); change
it to only clear the cookie for explicit auth-invalidating failures by checking
the error type/status: call clearAdminRefreshTokenCookie(res) only when the
error indicates authentication is invalid (for example error instanceof AppError
&& error.statusCode === 401) or other known token-invalid errors, and otherwise
leave the cookie intact so transient backend errors do not log the admin out.
- Around line 53-59: The current catch converts every non-AppError into a 400
and includes the raw exception text; instead, preserve AppError propagation (if
error instanceof AppError then next(error)), otherwise log the original
exception (using the module logger or console.error) and call next(new
AppError('Failed to exchange admin session', 500,
ERROR_CODES.INTERNAL_SERVER_ERROR_OR_AUTH_ERROR)) with a generic 5xx status and
without appending error.message to the response; update the block around the
existing next(new AppError(...)) call in admin session exchange to implement
this behavior.
In `@packages/dashboard/src/lib/api/client.ts`:
- Line 3: The change to CSRF_COOKIE_NAME breaks existing sessions because
getCsrfToken() only reads the new key; update getCsrfToken() to try the new
constant CSRF_COOKIE_NAME first and, if not found, fall back to the previous
cookie name (the legacy key) so pre-existing sessions continue to work during
rollout; ensure any write/update logic (where CSRF_COOKIE_NAME is used)
continues to set the new key while reads accept both names, and reference
CSRF_COOKIE_NAME and getCsrfToken() when implementing the fallback.
---
Outside diff comments:
In `@backend/src/infra/security/token.manager.ts`:
- Around line 103-117: The strict guards in verifyRefreshToken are currently
rejecting pre-rollout refresh tokens; add a compatibility path that accepts
legacy refresh tokens for a 7-day window: detect a legacy shape (e.g., missing
csrfNonce or missing/absent sessionType or older claim layout) and allow
verification if jwt.verify succeeds and the token's iat/exp indicates it was
issued within the last 7 days; for legacy tokens, populate default values for
csrfNonce/sessionType in the returned RefreshTokenPayload (or include a
legacyRefresh flag) so callers can force rotation and skip CSRF recomputation
for that flow. Apply the same compatibility changes to the corresponding logic
referenced around lines 208-225 so both verification paths accept tokens from
the 7-day horizon and mark them for exchange.
In `@packages/dashboard/src/features/login/services/login.service.ts`:
- Around line 89-97: The fetch call in login.service.ts uses a hardcoded
'/api/auth/admin/refresh' which bypasses getDashboardApiBaseUrl(); update the
code that builds the refresh URL to use getDashboardApiBaseUrl() (e.g., compute
const base = getDashboardApiBaseUrl(); then build the refresh endpoint as base +
'/api/auth/admin/refresh' or otherwise join paths to avoid double slashes) and
use that URL in the fetch call (the function performing the fetch is the admin
refresh request in login.service.ts). Ensure headers, credentials, and
AbortSignal.timeout(REQUEST_TIMEOUT_MS) remain unchanged.
🪄 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: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 14098402-43dc-4419-bb98-93642f851a33
📒 Files selected for processing (13)
backend/src/api/routes/auth/admin.routes.tsbackend/src/api/routes/auth/index.routes.tsbackend/src/api/routes/auth/oauth.routes.tsbackend/src/infra/security/token.manager.tsbackend/src/utils/cookies.tsbackend/tests/unit/auth-cookies.test.tsbackend/tests/unit/token-manager-csrf.test.tsdocs/core-concepts/authentication/architecture.mdxdocs/sdks/rest/auth.mdxopenapi/auth.yamlpackages/dashboard/src/features/login/services/login.service.tspackages/dashboard/src/lib/api/client.tspackages/shared-schemas/src/auth-api.schema.ts
| import { getDashboardApiBaseUrl } from '#lib/config/runtime'; | ||
|
|
||
| const CSRF_COOKIE_NAME = 'insforge_csrf'; | ||
| const CSRF_COOKIE_NAME = 'insforge_admin_csrf_token'; |
There was a problem hiding this comment.
Add a temporary legacy CSRF cookie fallback for rollout safety.
Line 3 switches the cookie key, but getCsrfToken() will no longer read pre-existing sessions using the old key. That can force immediate re-auth after deployment.
Suggested fix
-const CSRF_COOKIE_NAME = 'insforge_admin_csrf_token';
+const ADMIN_CSRF_COOKIE_NAME = 'insforge_admin_csrf_token';
+const LEGACY_CSRF_COOKIE_NAME = 'insforge_csrf';
@@
- document.cookie = `${CSRF_COOKIE_NAME}=${encodeURIComponent(csrfToken)}; expires=${expires}; path=/; SameSite=Lax`;
+ document.cookie = `${ADMIN_CSRF_COOKIE_NAME}=${encodeURIComponent(csrfToken)}; expires=${expires}; path=/; SameSite=Lax`;
@@
- document.cookie = `${CSRF_COOKIE_NAME}=; max-age=0; path=/; SameSite=Lax`;
+ document.cookie = `${ADMIN_CSRF_COOKIE_NAME}=; max-age=0; path=/; SameSite=Lax`;
+ document.cookie = `${LEGACY_CSRF_COOKIE_NAME}=; max-age=0; path=/; SameSite=Lax`;
@@
- const match = document.cookie.match(new RegExp(`(?:^|; )${CSRF_COOKIE_NAME}=([^;]*)`));
- return match ? decodeURIComponent(match[1]) : null;
+ const adminMatch = document.cookie.match(
+ new RegExp(`(?:^|; )${ADMIN_CSRF_COOKIE_NAME}=([^;]*)`)
+ );
+ if (adminMatch) {
+ return decodeURIComponent(adminMatch[1]);
+ }
+ const legacyMatch = document.cookie.match(
+ new RegExp(`(?:^|; )${LEGACY_CSRF_COOKIE_NAME}=([^;]*)`)
+ );
+ return legacyMatch ? decodeURIComponent(legacyMatch[1]) : null;🤖 Prompt for 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.
In `@packages/dashboard/src/lib/api/client.ts` at line 3, The change to
CSRF_COOKIE_NAME breaks existing sessions because getCsrfToken() only reads the
new key; update getCsrfToken() to try the new constant CSRF_COOKIE_NAME first
and, if not found, fall back to the previous cookie name (the legacy key) so
pre-existing sessions continue to work during rollout; ensure any write/update
logic (where CSRF_COOKIE_NAME is used) continues to set the new key while reads
accept both names, and reference CSRF_COOKIE_NAME and getCsrfToken() when
implementing the fallback.
There was a problem hiding this comment.
2 issues found across 13 files
Confidence score: 3/5
- There is some merge risk because
backend/src/api/routes/auth/admin.routes.tscurrently clears the admin refresh cookie on non-403 errors, which can force unexpected logouts during transient backend failures (severity 6/10, high confidence). - Error handling in
backend/src/api/routes/auth/admin.routes.tsalso appears to expose raw internal failure messages and maps unexpected exchange failures to400 INVALID_INPUT, which can misclassify server-side faults and leak internals. - Pay close attention to
backend/src/api/routes/auth/admin.routes.ts- tighten cookie-clearing conditions and return a generic server error for unexpected exchange failures.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="backend/src/api/routes/auth/admin.routes.ts">
<violation number="1" location="backend/src/api/routes/auth/admin.routes.ts:56">
P2: Avoid returning raw internal error messages and `400 INVALID_INPUT` for unexpected exchange failures; use a generic server error instead.</violation>
<violation number="2" location="backend/src/api/routes/auth/admin.routes.ts:147">
P2: Only clear the admin refresh cookie for authentication-invalidating failures (for example 401). Clearing it for every non-403 error turns transient backend failures into forced logout.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
jwfing
left a comment
There was a problem hiding this comment.
Code Review — Fix admin refresh & persist CSRF along session
Summary: The approach is sound and the core security design is well-executed. The two root bugs (CSRF instability across rotation and admin/user cookie collision) are fixed correctly. No blocking issues; a few suggestions below.
Requirements context
No matching spec or plan was found under /docs/superpowers/specs/ or /docs/superpowers/plans/ for this change. Assessment is against the PR description and linked behaviour: (1) CSRF token must survive refresh-token rotation, (2) admin and user cookies must be independent.
Findings
Critical
(none)
Suggestion
[Software Engineering] Redundant verifyRefreshToken round-trip after generateRefreshToken
backend/src/api/routes/auth/admin.routes.ts:45 and :87
const csrfToken = tokenManager.generateCsrfToken(tokenManager.verifyRefreshToken(refreshToken));The payload is already known (you just assembled it in generateRefreshToken). Calling verifyRefreshToken immediately after forces an unnecessary sign→verify round-trip. generateCsrfToken accepts a RefreshTokenPayload, so you can construct the struct inline instead:
const nonce = tokenManager.generateCsrfNonce();
const refreshToken = tokenManager.generateRefreshToken(result.user.id, 'admin', nonce);
const payload: RefreshTokenPayload = { sub: result.user.id, type: 'refresh', iss: 'insforge', csrfNonce: nonce, sessionType: 'admin' };
const csrfToken = tokenManager.generateCsrfToken(payload);Or export a helper that returns both at once.
[Security] CSRF cookie missing Secure attribute
packages/dashboard/src/lib/api/client.ts:25
document.cookie = `${CSRF_COOKIE_NAME}=${encodeURIComponent(csrfToken)}; expires=${expires}; path=/; SameSite=Lax`;The Secure flag is absent. The CSRF token would be transmitted in plaintext over HTTP. The production dashboard is presumably HTTPS-only, but adding ; Secure is cheap defence-in-depth and consistent with how the refresh cookies are configured.
[Software Engineering] No integration tests for the new HTTP endpoints
backend/src/api/routes/auth/admin.routes.ts (all four routes)
The unit tests in auth-cookies.test.ts and token-manager-csrf.test.ts cover the helpers well. But there are no tests exercising the actual routes (correct cookie header set, 401 on missing cookie, 403 on bad CSRF, cookie preserved on 403 but cleared on 401, is_project_admin guard, etc.). The PR description itself says "Testing in progress" — these should land before or alongside merge.
[Security] Stable CSRF nonce → stolen CSRF token valid for full 7-day session
backend/src/infra/security/token.manager.ts:133 (admin refresh) / index.routes.ts:612 (user refresh)
The nonce is intentionally kept constant across rotations to solve the flakiness bug (good call). The consequence is that a leaked CSRF token (e.g., read via devtools, XSS, or shared browser profile) remains valid for the entire 7-day session lifetime — it never rotates. This trade-off is reasonable given the bug being fixed, but is worth noting in a code comment or SECURITY.md for future maintainers.
Information
Breaking change: legacy refresh tokens rejected on deploy
backend/src/infra/security/token.manager.ts:111-117
verifyRefreshToken now rejects tokens that lack csrfNonce or sessionType. Any session established before this deploy (all current logged-in users and admins) will be rejected and forced to re-authenticate. The PR mentions this in the Macroscope note. Ops should plan for a coordinated deploy or an in-browser banner; no code change needed, just awareness.
No matching spec/plan in /docs/superpowers/
No existing spec or plan document covers admin session separation or CSRF persistence. Assessing against PR description alone.
Global rate limiter covers admin routes (3 000 req / 15 min / IP)
backend/src/server.ts:86-91 — the global rate limiter is in place, so POST /api/auth/admin/sessions is not completely unguarded. A tighter, credential-specific limiter would be better hardening but is outside the stated scope of this PR.
Verdict
approved (informational — explicit GitHub approval via the approve flow is a separate human action)
The CSRF stability fix (nonce embedded in token, HMAC-derived CSRF) and the admin/user cookie separation are both implemented correctly. timingSafeEqual, httpOnly, path-scoping, and session-type guards are all in the right places. The Suggestions above — especially integration tests and the Secure cookie attribute — should be addressed before or shortly after merge.
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/dashboard/src/lib/api/client.ts (1)
54-59:⚠️ Potential issue | 🟠 Major | ⚡ Quick winKeep a way to make unauthenticated requests.
Removing
skipAuthmeans every call now inherits the dashboard bearer token and the 401 refresh flow. That changes public endpoints likepackages/dashboard/src/lib/services/health.service.tsLine 10 from pure liveness checks into auth-dependent calls, and it also prevents callers from intentionally omitting or overridingAuthorization.Also applies to: 62-70, 100-113
🤖 Prompt for 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. In `@packages/dashboard/src/lib/api/client.ts` around lines 54 - 59, The request function removed the ability to make unauthenticated calls; restore an explicit skipAuth (or allowUnauthenticated) flag in request's options signature and use it when building headers so the dashboard bearer token / 401 refresh flow is only applied when skipAuth is false; specifically, update the request(...) options to include skipAuth?: boolean, and change the header logic in request (and related branches around the token/refresh flow in the same file) to: if skipAuth is truthy or an Authorization header is already present, do not inject the dashboard bearer token or trigger the refresh flow, otherwise attach Authorization and proceed with refresh-on-401 behavior.
🧹 Nitpick comments (1)
backend/tests/unit/auth-admin-routes.test.ts (1)
5-36: 🏗️ Heavy liftAvoid source-text assertions for auth behavior.
These assertions are coupled to exact log strings and expressions, so they can pass while the handler behavior is wrong and fail on harmless refactors. Please exercise the route/cookie behavior directly in unit tests instead of inspecting source text.
🤖 Prompt for 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. In `@backend/tests/unit/auth-admin-routes.test.ts` around lines 5 - 36, The test uses brittle source-text assertions against admin.routes.ts and auth route files (checking for logger.error strings, ERROR_CODES.INTERNAL_ERROR, AppError checks, and specific calls like generateCsrfToken(tokenManager.verifyRefreshToken) or verifyRefreshToken(newRefreshToken)) — replace these with behavioral unit tests that exercise the route handlers instead: invoke the admin auth exchange and assert the HTTP response is a generic server error and that internal error codes are returned rather than relying on the exact logger message; simulate non-auth transient refresh failures and assert the admin refresh cookie is preserved (check Set-Cookie/no cookie deletion) and that 401 vs 403 behavior matches expectations rather than string-matching on "error instanceof AppError"; for CSRF/refresh token behavior, call the refresh/token issuance flow and assert you do not perform an extra verify step on a freshly issued refresh token (i.e., generated refresh token is returned with CSRF derived via generateRefreshTokenWithCsrf semantics) instead of searching for occurrences of generateCsrfToken(tokenManager.verifyRefreshToken) or verifyRefreshToken(newRefreshToken) in source text.
🤖 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 `@backend/tests/unit/auth-admin-routes.test.ts`:
- Around line 24-27: The assertion is checking for an auth failure (401) but the
test intends to verify non-auth transient refresh failures; update the
expectations in the test to look for a transient status (e.g., 503) instead of
401. Specifically, in the test that references adminRoutesSource, replace the
check that looks for 'error instanceof AppError && error.statusCode === 401'
with one that looks for 'error instanceof AppError && error.statusCode === 503',
and flip the negative assertion to ensure it does not contain 'error.statusCode
=== 401' so the test asserts non-auth transient behavior.
---
Outside diff comments:
In `@packages/dashboard/src/lib/api/client.ts`:
- Around line 54-59: The request function removed the ability to make
unauthenticated calls; restore an explicit skipAuth (or allowUnauthenticated)
flag in request's options signature and use it when building headers so the
dashboard bearer token / 401 refresh flow is only applied when skipAuth is
false; specifically, update the request(...) options to include skipAuth?:
boolean, and change the header logic in request (and related branches around the
token/refresh flow in the same file) to: if skipAuth is truthy or an
Authorization header is already present, do not inject the dashboard bearer
token or trigger the refresh flow, otherwise attach Authorization and proceed
with refresh-on-401 behavior.
---
Nitpick comments:
In `@backend/tests/unit/auth-admin-routes.test.ts`:
- Around line 5-36: The test uses brittle source-text assertions against
admin.routes.ts and auth route files (checking for logger.error strings,
ERROR_CODES.INTERNAL_ERROR, AppError checks, and specific calls like
generateCsrfToken(tokenManager.verifyRefreshToken) or
verifyRefreshToken(newRefreshToken)) — replace these with behavioral unit tests
that exercise the route handlers instead: invoke the admin auth exchange and
assert the HTTP response is a generic server error and that internal error codes
are returned rather than relying on the exact logger message; simulate non-auth
transient refresh failures and assert the admin refresh cookie is preserved
(check Set-Cookie/no cookie deletion) and that 401 vs 403 behavior matches
expectations rather than string-matching on "error instanceof AppError"; for
CSRF/refresh token behavior, call the refresh/token issuance flow and assert you
do not perform an extra verify step on a freshly issued refresh token (i.e.,
generated refresh token is returned with CSRF derived via
generateRefreshTokenWithCsrf semantics) instead of searching for occurrences of
generateCsrfToken(tokenManager.verifyRefreshToken) or
verifyRefreshToken(newRefreshToken) in source text.
🪄 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: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: bae9163a-4e6d-494e-bb95-3fe51903fd52
📒 Files selected for processing (11)
backend/src/api/routes/auth/admin.routes.tsbackend/src/api/routes/auth/index.routes.tsbackend/src/api/routes/auth/oauth.routes.tsbackend/src/infra/security/token.manager.tsbackend/tests/unit/auth-admin-routes.test.tsbackend/tests/unit/token-manager-csrf.test.tspackages/dashboard/src/features/login/services/login.service.test.tspackages/dashboard/src/features/login/services/login.service.tspackages/dashboard/src/lib/api/client.test.tspackages/dashboard/src/lib/api/client.tspackages/dashboard/src/lib/services/health.service.ts
🚧 Files skipped from review as they are similar to previous changes (4)
- backend/src/api/routes/auth/oauth.routes.ts
- backend/src/api/routes/auth/admin.routes.ts
- backend/src/infra/security/token.manager.ts
- backend/tests/unit/token-manager-csrf.test.ts
jwfing
left a comment
There was a problem hiding this comment.
Summary
Fixes flaky admin refresh and cross-session logout by embedding a stable csrfNonce + sessionType in every refresh JWT, and isolating the admin refresh cookie to /api/auth/admin. The approach is architecturally sound; no Critical blockers.
Requirements context
No spec or plan under /docs/superpowers/specs/ or /docs/superpowers/plans/ matched this PR's scope — assessing against the PR description alone.
Findings
Critical
(none)
Suggestion
Security
S1 — POST /api/auth/admin/sessions is unrate-limited (backend/src/api/routes/auth/admin.routes.ts:58–84)
This endpoint validates email+password against static env-variable credentials. It has no rate limiter. An attacker with network access to the backend can brute-force admin credentials without constraint. The pre-existing route in index.routes.ts was also unrate-limited, but the migration to a dedicated router is a clean opportunity to fix this. Consider adding a lightweight per-IP limiter (e.g., sendEmailOTPRateLimiter or a new adminLoginRateLimiter) as middleware on this route. The sessions/exchange OAuth path does not have this problem since it validates a cloud-issued JWT, not a password.
S2 — Logout endpoint can be CSRF-triggered without authentication (backend/src/api/routes/auth/admin.routes.ts:141–152)
POST /api/auth/admin/logout clears the admin refresh cookie unconditionally, with no CSRF token or valid-cookie check. Because the admin refresh cookie carries SameSite=none, a cross-origin page can trigger a logout request that includes the cookie and force the admin to re-authenticate. This is a session-disruption vector rather than a session-takeover, but it's cheap to close: either require X-CSRF-Token here (same as refresh), or at minimum verify the admin cookie exists before clearing it.
Software Engineering
S3 — auth-admin-routes.test.ts tests source text, not behavior (backend/tests/unit/auth-admin-routes.test.ts:1–37)
All three test cases read the .ts source file with readFileSync and assert on string literals (e.g., toContain("logger.error('[Auth:AdminSessionExchange]...")). This validates that a specific string exists in the source file, not that the route actually behaves correctly under those conditions. A log message rename or code reformat would fail the test with no real regression. These would be much more valuable as actual route-level tests (using supertest against an Express app, matching the pattern in other test files in this repo) that assert HTTP response codes and cookie behaviour.
S4 — generateCsrfNonce() is unintentionally public (backend/src/infra/security/token.manager.ts:250–252)
generateCsrfNonce() is an internal implementation detail of generateRefreshToken / generateRefreshTokenWithCsrf. Callers outside TokenManager have no reason to call it. Making it private keeps the interface clean and prevents future callers from accidentally generating nonces that bypass the standard token-creation path.
Information
I1 — Asymmetric cookie-clearing strategy between user and admin refresh handlers
- User
/refresh(index.routes.ts:615): clears cookie on all errors except 403 (CSRF mismatch). - Admin
/refresh(admin.routes.ts:133): clears cookie only on 401; all other errors (including 500s) leave the cookie intact.
Both preserve the cookie on CSRF failure (the critical invariant), but the admin path is more conservative — a transient DB error won't end an admin session. This is a reasonable product decision, but the asymmetry is not documented and may surprise future maintainers. A brief comment in the admin catch block explaining why 500s are treated differently from user sessions would help.
I2 — generateRefreshTokenWithCsrf is called for non-web client token rotation (backend/src/api/routes/auth/index.routes.ts:591–592)
In the user /refresh endpoint, generateRefreshTokenWithCsrf is always called (even for non-web clients), and the resulting csrfToken is discarded for that branch. The cost is negligible (one HMAC), but using generateRefreshToken for the non-web path would be more explicit about intent and avoids computing a value that's immediately thrown away.
I3 — Breaking change: all active refresh tokens will be invalidated on deploy
verifyRefreshToken now hard-rejects any token missing csrfNonce or sessionType. Every existing session (user and admin) will fail its first refresh after this ships and require re-authentication. This is called out in the PR description, but worth ensuring is communicated in release notes / deployment runbook so operators aren't surprised by a support spike.
Verdict
approved (informational — no Critical findings; human approval still required via the approve flow)
The CSRF design is solid: nonce is embedded in the signed JWT (not derivable without the secret), HMAC input includes sessionType to prevent cross-session reuse, and timingSafeEqual is used for the comparison. The cookie-path separation correctly isolates admin and user sessions. The test coverage in token-manager-csrf.test.ts and auth-cookies.test.ts is good; the main gap is the source-text assertions in auth-admin-routes.test.ts (S3 above). Address S1 and S2 before or shortly after ship.
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
backend/tests/unit/auth-admin-routes.test.ts (1)
1-211:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winFix Prettier violations in this file before merge.
Pipeline reports formatting drift for this test file. Please run Prettier on this file to clear the lint-and-format warning.
🤖 Prompt for 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. In `@backend/tests/unit/auth-admin-routes.test.ts` around lines 1 - 211, Run the project's Prettier formatter on the test file to fix formatting drift: apply the configured Prettier rules (e.g., via your local npm script like npm run format or npx prettier --write) to the test suite containing describe('admin auth route review regressions', ...) and the helper functions createAdminAuthApp and postAdminRefresh so the file no longer reports lint/format violations; commit the reformatted file.
🤖 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 `@backend/tests/unit/auth-admin-routes.test.ts`:
- Around line 67-83: The helper postAdminRefresh currently uses global fetch
which is being interfered with in tests; replace it to perform a test-local
request against the Express app returned by createAdminAuthApp instead (use
supertest or Node http.request) so you don't start a real listener or depend on
global fetch. Update postAdminRefresh to accept headers, call
createAdminAuthApp() and issue a POST to /api/auth/admin/refresh via supertest
(or create a local request against the app), then return the response.status and
response.headers['set-cookie'] (preserving the current return shape) and ensure
the server is not bound to a real port or global fetch is avoided.
---
Outside diff comments:
In `@backend/tests/unit/auth-admin-routes.test.ts`:
- Around line 1-211: Run the project's Prettier formatter on the test file to
fix formatting drift: apply the configured Prettier rules (e.g., via your local
npm script like npm run format or npx prettier --write) to the test suite
containing describe('admin auth route review regressions', ...) and the helper
functions createAdminAuthApp and postAdminRefresh so the file no longer reports
lint/format violations; commit the reformatted file.
🪄 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: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 1ba4c428-7889-4b9a-a5dd-ad40285f2be2
📒 Files selected for processing (3)
backend/src/api/routes/auth/index.routes.tsbackend/src/infra/security/token.manager.tsbackend/tests/unit/auth-admin-routes.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- backend/src/infra/security/token.manager.ts
- backend/src/api/routes/auth/index.routes.ts
| async function postAdminRefresh(headers: Record<string, string>) { | ||
| const server = createAdminAuthApp().listen(0); | ||
| await new Promise<void>((resolve) => server.once('listening', resolve)); | ||
|
|
||
| try { | ||
| const { port } = server.address() as AddressInfo; | ||
| const response = await fetch(`http://127.0.0.1:${port}/api/auth/admin/refresh`, { | ||
| method: 'POST', | ||
| headers, | ||
| }); | ||
| await response.text(); | ||
|
|
||
| return { | ||
| status: response.status, | ||
| setCookie: response.headers.get('set-cookie'), | ||
| }; | ||
| } finally { |
There was a problem hiding this comment.
Replace global fetch in postAdminRefresh with a test-local request mechanism.
postAdminRefresh is currently the direct cause of CI failures (response is undefined at Line 77), which makes three regression tests fail before assertions run. Use supertest (or Node http.request) against the Express app to avoid global fetch interference in Vitest.
Proposed fix (Supertest-based helper)
+import request from 'supertest';
...
-async function postAdminRefresh(headers: Record<string, string>) {
- const server = createAdminAuthApp().listen(0);
- await new Promise<void>((resolve) => server.once('listening', resolve));
-
- try {
- const { port } = server.address() as AddressInfo;
- const response = await fetch(`http://127.0.0.1:${port}/api/auth/admin/refresh`, {
- method: 'POST',
- headers,
- });
- await response.text();
-
- return {
- status: response.status,
- setCookie: response.headers.get('set-cookie'),
- };
- } finally {
- await new Promise<void>((resolve, reject) => {
- server.close((error) => {
- if (error) {
- reject(error);
- return;
- }
-
- resolve();
- });
- });
- }
-}
+async function postAdminRefresh(headers: Record<string, string>) {
+ const app = createAdminAuthApp();
+ const response = await request(app).post('/api/auth/admin/refresh').set(headers).send();
+ const setCookieHeader = response.header['set-cookie'];
+
+ return {
+ status: response.status,
+ setCookie: Array.isArray(setCookieHeader) ? setCookieHeader.join('; ') : null,
+ };
+}🧰 Tools
🪛 GitHub Actions: Unit Tests / 0_Unit Tests.txt
[error] 77-77: TypeError: Cannot read properties of undefined (reading 'text') in postAdminRefresh where the test awaits response.text().
🪛 GitHub Actions: Unit Tests / Unit Tests
[error] 77-77: Vitest test run failed in auth-admin-routes.test.ts (npm run test:backend > vitest run). TypeError: Cannot read properties of undefined (reading 'text') at postAdminRefresh; failing assertions at response.text().
🪛 GitHub Check: Unit Tests
[failure] 77-77: tests/unit/auth-admin-routes.test.ts > admin auth route review regressions > clears admin refresh cookies on auth-invalidating refresh failures
TypeError: Cannot read properties of undefined (reading 'text')
❯ postAdminRefresh tests/unit/auth-admin-routes.test.ts:77:20
❯ tests/unit/auth-admin-routes.test.ts:175:22
[failure] 77-77: tests/unit/auth-admin-routes.test.ts > admin auth route review regressions > preserves admin refresh cookies on CSRF rejection
TypeError: Cannot read properties of undefined (reading 'text')
❯ postAdminRefresh tests/unit/auth-admin-routes.test.ts:77:20
❯ tests/unit/auth-admin-routes.test.ts:161:22
[failure] 77-77: tests/unit/auth-admin-routes.test.ts > admin auth route review regressions > preserves admin refresh cookies on non-auth transient refresh failures
TypeError: Cannot read properties of undefined (reading 'text')
❯ postAdminRefresh tests/unit/auth-admin-routes.test.ts:77:20
❯ tests/unit/auth-admin-routes.test.ts:149:22
🤖 Prompt for 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.
In `@backend/tests/unit/auth-admin-routes.test.ts` around lines 67 - 83, The
helper postAdminRefresh currently uses global fetch which is being interfered
with in tests; replace it to perform a test-local request against the Express
app returned by createAdminAuthApp instead (use supertest or Node http.request)
so you don't start a real listener or depend on global fetch. Update
postAdminRefresh to accept headers, call createAdminAuthApp() and issue a POST
to /api/auth/admin/refresh via supertest (or create a local request against the
app), then return the response.status and response.headers['set-cookie']
(preserving the current return shape) and ensure the server is not bound to a
real port or global fetch is avoided.
Summary
Persist CSRF token along the same login session. This is the main bug that constantly fails the refresh request
Separate admin cookies with end user cookies. This is the main bug that logs out the developer
How did you test this change?
Testing in progress
Summary by cubic
Fixes flaky admin refresh by persisting CSRF across a session and separating admin vs user refresh flows to prevent cross-logouts. Adds admin-only routes, cookies, and dashboard client updates; enforces session types and stabilizes CSRF across rotation.
Bug Fixes
csrfNonce,sessionType) and is verified against them; stable across rotation; legacy tokens without claims are rejected.sessionType(user/admin); web refresh validatesX-CSRF-Token; non-web refresh returns refresh tokens in the body; cookies are cleared only on 401.insforge_admin_refresh_tokenscoped to/api/auth/adminandinsforge_refresh_tokenfor app sessions.New Features
POST /api/auth/admin/sessions,/sessions/exchange,/refresh,/logout; login/refresh return acsrfToken.TokenManager: addedgenerateRefreshTokenWithCsrf; refresh payloads carrycsrfNonceandsessionType.insforge_admin_csrf_token(SameSite=Lax, Secure);ApiClientalways sends Authorization and removesskipAuth.openapi/auth.yamlupdated; tests added for CSRF derivation, cookie paths, admin routes, and dashboard client.Written for commit 1e5b302. Summary will update on new commits. Review in cubic
Note
Fix admin token refresh by adding CSRF persistence across session rotation
/api/auth/admin/refresh,/api/auth/admin/logout,/api/auth/admin/sessions,/api/auth/admin/sessions/exchange) in a new admin.routes.ts, separating admin auth from user auth.csrfNonceandsessionType('user'or'admin') in refresh token payloads; CSRF tokens are now derived from these claims via HMAC, making them stable across token rotation./api/auth/admin, independent of the user cookie path.X-CSRF-Tokenon refresh requests.csrfNonceorsessionTypeclaims are rejected, requiring re-authentication for any existing sessions.Changes since #1307 opened
TokenManager.generateRefreshTokenWithCsrf[3b93929]LoginService.refreshAccessTokenmethod in dashboard to useapiClient.requestwith CSRF token header [3b93929]Secureattribute to CSRF cookie operations in dashboardApiClient[3b93929]skipAuthoption fromApiClient.requestmethod and eliminated conditional Authorization header attachment [3b93929]/api/auth/refreshhandler to conditionally generate CSRF-paired refresh tokens for web clients and non-CSRF refresh tokens for non-web clients, and changed error handling to clear refresh cookies only on 401 errors [678b391]TokenManager.generateCsrfNoncemethod from public to private [678b391]Macroscope summarized 911ff30.
Summary by CodeRabbit
New Features
Documentation
Tests