Skip to content

security: implement server-side session management - #6

Open
isaJrKai wants to merge 1 commit into
mainfrom
fix/session-management
Open

security: implement server-side session management#6
isaJrKai wants to merge 1 commit into
mainfrom
fix/session-management

Conversation

@isaJrKai

@isaJrKai isaJrKai commented Jun 29, 2026

Copy link
Copy Markdown
Owner

What this fixes

Replaces JWT-only authentication with server-side sessions. JWTs now contain a sessionId that must exist in the Session table AND must not be revoked AND must not be expired.

This closes the "founder returns after logout" bug: even if a browser cache or another tab holds the JWT, the revoked session row makes getAuthUser() return null.

⚠️ MIGRATION REQUIRED — read before merging

Before merging this PR, run the migration SQL against Neon:

  1. Open Neon console → your project → SQL Editor
  2. Paste the contents of prisma/migrations/20260625000000_add_session_revocation.sql
  3. Run
ALTER TABLE "Session" ADD COLUMN "revokedAt" TIMESTAMP(3);
ALTER TABLE "Session" ADD COLUMN "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
CREATE INDEX "Session_revokedAt_idx" ON "Session"("revokedAt");
CREATE INDEX "Session_lastSeenAt_idx" ON "Session"("lastSeenAt");

This is safe to run while the app is live — adds nullable/defaulted columns, no table lock.

If you merge without running the migration first, the app will 500 on every authenticated request until you run it.

⚠️ All existing sessions invalidated

All existing JWTs (without sessionId) are auto-rejected on deploy. Every user must log in again. This is expected — the old JWTs were issued under the less-secure model.

Changes

1. Schema (prisma/schema.prisma)

  • Added revokedAt DateTime? — NULL = active, set = revoked
  • Added lastSeenAt DateTime @default(now()) — refreshed on each request (debounced)
  • Added indexes on both columns

2. src/lib/auth.ts (core fix)

  • SessionPayload now includes sessionId
  • signSession() requires sessionId (throws if missing)
  • verifySession() rejects JWTs without sessionId (kills legacy tokens)
  • clearSessionCookie() uses matching RFC 6265 attributes (HttpOnly, SameSite, Secure, Path, Expires, Max-Age)
  • New: createSessionRecord(), revokeSession(), revokeAllUserSessions(), isSessionValid(), listUserSessions()
  • getAuthUser() now calls isSessionValid(sessionId) — JWT alone is not enough
  • logout() revokes the server-side session FIRST, then clears cookie
  • All login paths (attemptLogin, completeLoginWith2FA, performFounderSetup) create session records
  • lastSeenAt refreshed with 60-second debounce (powers future admin UI)

3. src/lib/auth-edge.ts

  • sessionId added to SessionPayload
  • Trust model documented: middleware does JWT-only check (Edge can't access DB); getAuthUser() does the full DB check

4. src/app/api/auth/logout/route.ts

  • Reads sessionId from JWT, passes to logout() so the session is revoked

5. src/app/api/auth/child-login/route.ts

  • Creates a Session record on child login (was missing)

6. src/app/login/page.tsx

  • If visitor has a valid session, redirects to dashboard (prevents "I'm logged in but seeing /login")

7. src/app/api/admin/security/route.ts

  • Preserves sessionId when re-signing JWT after email change

8. src/app/api/admin/sessions/route.ts (NEW)

  • GET /api/admin/sessions — list all sessions for current founder
  • POST /api/admin/sessions — revoke by sessionId or all: true

9. src/app/api/state/route.ts + src/app/api/mutations/route.ts

  • Added getAuthUser() auth gates (were relying on middleware alone)

Acceptance tests

All 5 tests pass locally (12/12 sub-checks):

Test Description Status
TEST 1 Login → refresh → still logged in
TEST 2 Login → logout → refresh → remain logged out
TEST 3 Two browsers — both logged in
TEST 4 Logout browser A → browser B still works
TEST 5 Server revokes session B → B loses access immediately

Files changed (11 files)

  • prisma/schema.prisma — Session model: +2 columns, +2 indexes
  • prisma/migrations/20260625000000_add_session_revocation.sql (new) — Neon migration
  • src/lib/auth.ts — core session lifecycle
  • src/lib/auth-edge.ts — sessionId in payload
  • src/app/api/auth/logout/route.ts — revokes session
  • src/app/api/auth/child-login/route.ts — creates session
  • src/app/login/page.tsx — redirect if valid session
  • src/app/api/admin/security/route.ts — preserve sessionId
  • src/app/api/admin/sessions/route.ts (new) — list/revoke endpoint
  • src/app/api/state/route.ts — auth gate
  • src/app/api/mutations/route.ts — auth gate

Verification

  • npx tsc --noEmit — clean
  • npx eslint — clean
  • npx prisma generate — clean
  • ✅ All 5 acceptance tests pass locally

Vercel env vars

None needed. JWT_SECRET is already set (verified).

Deployment steps (in order)

  1. Run the migration on Neon (SQL file in this PR)
  2. Merge this PR
  3. Vercel auto-deploys
  4. All existing sessions are invalidated — log in again
  5. Verify: login → logout → refresh → stays logged out ✅

Summary by CodeRabbit

  • New Features

    • Added session management improvements, including active session tracking, revocation, and better session activity updates.
    • Admins can now view and revoke user sessions, including ending all sessions at once.
  • Bug Fixes

    • Strengthened login and logout behavior so signed-in users are redirected appropriately and sessions are properly cleared.
    • Protected authenticated areas and actions by requiring a valid signed-in session before access.

Replaces JWT-only authentication with server-side sessions. JWTs now
contain a sessionId that must exist in the Session table AND must not
be revoked AND must not be expired.

Changes:

1. Schema: add revokedAt + lastSeenAt columns to Session model
2. auth.ts: sessionId in JWT, createSessionRecord on every login,
   isSessionValid check in getAuthUser, revokeSession on logout,
   clearSessionCookie with matching RFC 6265 attributes, debounced
   lastSeenAt refresh
3. auth-edge.ts: sessionId in SessionPayload
4. logout route: revokes session by sessionId before clearing cookie
5. child-login route: creates session record
6. login page: redirects to dashboard if valid session exists
7. admin/security: preserves sessionId when re-signing JWT
8. admin/sessions (NEW): list + revoke sessions endpoint
9. api/state + api/mutations: add getAuthUser auth gates

All login paths (parent, 2FA, founder setup, child) now create session
records. Logout revokes only the current device session. Multi-device
sessions are independent.

BREAKING: All existing JWTs (without sessionId) are auto-rejected on
deploy. Every user must log in again. This is expected and correct —
the old JWTs were issued under the less-secure model.

Migration: prisma/migrations/20260625000000_add_session_revocation.sql
must be run against Neon BEFORE merging this PR.
@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Introduces server-side session revocation by adding revokedAt and lastSeenAt columns to the Session table, embedding sessionId in JWTs, and wiring session creation/validation/revocation across all login, logout, and setup flows. Adds auth gates to /api/state and /api/mutations, a new /api/admin/sessions endpoint for listing and revoking sessions, and redirects authenticated users away from the login page.

Changes

Server-side Session Revocation

Layer / File(s) Summary
DB schema: Session fields and indexes
prisma/migrations/20260625000000_add_session_revocation.sql, prisma/schema.prisma
Adds nullable revokedAt and non-null lastSeenAt (default CURRENT_TIMESTAMP) columns plus indexes to the Session table in both the migration SQL and Prisma schema.
SessionPayload contract and JWT sign/verify
src/lib/auth-edge.ts, src/lib/auth.ts
Extends SessionPayload with required sessionId; updates signSession, verifySession, and getSessionFromCookieHeader to require and embed sessionId in JWT claims, returning null if missing.
Session lifecycle helpers
src/lib/auth.ts
Adds createSessionRecord, revokeSession, revokeAllUserSessions, isSessionValid (with in-memory debounced lastSeenAt updates), and listUserSessions. Hardens clearSessionCookie with RFC-6265-safe deletion attributes.
Login, logout, and founder setup wired to session records
src/lib/auth.ts, src/app/api/auth/child-login/route.ts, src/app/api/auth/logout/route.ts
attemptLogin, completeLoginWith2FA, performFounderSetup, and child login now create a DB session record and include sessionId in the signed JWT and audit log. logout accepts sessionId/revokeAll and revokes server-side sessions before clearing the cookie. getAuthUser validates sessionId against the DB.
Auth gates on /api/state, /api/mutations, and login redirect
src/app/api/state/route.ts, src/app/api/mutations/route.ts, src/app/login/page.tsx
Adds getAuthUser() early-return 401 to GET /api/state and POST /api/mutations. Login page redirects already-authenticated users to /child or the next path before rendering the form.
Admin session management and security route update
src/app/api/admin/sessions/route.ts, src/app/api/admin/security/route.ts
New GET/POST /api/admin/sessions handlers list and revoke founder sessions with audit logging. Admin security POST now retrieves sessionId from the current session before re-signing after an email change.

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant LoginRoute
  participant createSessionRecord
  participant DB
  participant signSession
  participant getAuthUser
  participant isSessionValid

  Browser->>LoginRoute: POST credentials
  LoginRoute->>DB: verify user
  LoginRoute->>createSessionRecord: ip, userAgent, expires
  createSessionRecord->>DB: INSERT session row
  DB-->>createSessionRecord: { id, sessionToken }
  createSessionRecord-->>LoginRoute: sessionId
  LoginRoute->>signSession: payload + sessionId
  signSession-->>LoginRoute: JWT
  LoginRoute-->>Browser: Set-Cookie JWT

  Browser->>getAuthUser: request with cookie
  getAuthUser->>isSessionValid: sessionId from JWT
  isSessionValid->>DB: SELECT session (not revoked, not expired)
  DB-->>isSessionValid: row or null
  isSessionValid-->>getAuthUser: true | false
  getAuthUser-->>Browser: user or 401
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇 A session is born with an ID and a key,
Tucked into the cookie for all hops to see.
When revokedAt is set, the door swings shut tight,
No stale JWT sneaks through in the night.
The rabbit hops safe — each session tracked right! 🔒

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 34.62% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: moving authentication to server-side session management.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/session-management

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/app/api/mutations/route.ts (1)

32-42: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Add per-action authorization before dispatching mutations.

Line 34 only checks that a session is valid, but this route then accepts any action; child sessions can reach parent/admin-style mutations like setFamilySettings, createParent, createChild, and giveTokens. Add an explicit role/action policy or pass user into mutation helpers that enforce ownership and role checks.

🛡️ Suggested shape
   try {
     const body = await req.json();
     const { action, payload } = body as { action: string; payload: any };
+
+    if (!canDispatchMutation(user, action, payload)) {
+      return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+    }
 
     switch (action) {
🤖 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 `@src/app/api/mutations/route.ts` around lines 32 - 42, The POST handler in
mutations/route.ts only validates that getAuthUser() returns a session, but then
dispatches any requested action without role checks. Add per-action
authorization in POST before the switch on action, or pass the user context into
the mutation helpers so setFamilySettings, createParent, createChild, and
giveTokens can enforce ownership and role-based access. Keep the policy close to
the action dispatch so only allowed actions for the current user can execute.
src/app/api/admin/security/route.ts (1)

91-105: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Validate the current session before committing the email change.

Lines 101-104 run after the transaction, so the email and superAdminEmail can already be updated before returning 401, and the success audit log is skipped. Read and validate the sessionId before the transaction when updates.email is set.

🐛 Proposed fix
+    const emailSession = updates.email ? await getSession() : null;
+    if (updates.email && !emailSession?.sessionId) {
+      return NextResponse.json({ ok: false, error: "Session expired — please log in again" }, { status: 401 });
+    }
+
     await db.$transaction(async (tx) => {
       await tx.user.update({ where: { id: user.id }, data: updates });
       if (updates.email) {
         await tx.systemSettings.update({ where: { id: "singleton" }, data: { superAdminEmail: updates.email } });
       }
     });
 
-    if (updates.email) {
-      // Preserve the existing sessionId — the user's session is still valid,
-      // only the email claim in the JWT needs to be refreshed.
-      const currentSession = await getSession();
-      if (!currentSession?.sessionId) {
-        return NextResponse.json({ ok: false, error: "Session expired — please log in again" }, { status: 401 });
-      }
-      const token = await signSession({ sub: user.id, email: updates.email, platformRole: user.platformRole, familyRole: user.familyRole, sessionId: currentSession.sessionId });
+    if (updates.email && emailSession?.sessionId) {
+      const token = await signSession({ sub: user.id, email: updates.email, platformRole: user.platformRole, familyRole: user.familyRole, sessionId: emailSession.sessionId });
       await setSessionCookie(token);
     }
🤖 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 `@src/app/api/admin/security/route.ts` around lines 91 - 105, The session check
for email changes is happening after the database transaction in the security
admin route, so a missing/expired session can still allow `tx.user.update` and
`tx.systemSettings.update` to commit before returning 401. Move the
`getSession()`/`sessionId` validation ahead of the `db.$transaction` inside the
`route.ts` handler, gated by `updates.email`, and only proceed to the
transaction and `signSession` after confirming the current session is valid.
🧹 Nitpick comments (2)
src/app/api/auth/logout/route.ts (1)

7-16: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Pass request metadata into logout audit logging.

logout() already records ipAddress/userAgent when provided; wiring them here keeps logout audit entries as useful as login entries.

Proposed change
-import { NextResponse } from "next/server";
+import { NextRequest, NextResponse } from "next/server";
 import { logout, getSession } from "`@/lib/auth`";
@@
-export async function POST() {
+export async function POST(req: NextRequest) {
@@
   const session = await getSession();
+  const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
+  const ua = req.headers.get("user-agent") ?? "unknown";
   await logout({
     userId: session?.sub,
     sessionId: session?.sessionId,
+    ipAddress: ip,
+    userAgent: ua,
   });
🤖 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 `@src/app/api/auth/logout/route.ts` around lines 7 - 16, The POST handler in
logout/route.ts calls logout() without passing request metadata, so logout audit
entries miss IP and user agent. Update the POST function to accept/use the
incoming request object and extract the same metadata already supported by
logout() (ipAddress and userAgent), then include those fields in the logout call
alongside userId and sessionId. Keep the fix localized to POST and the logout()
invocation so logout logging stays consistent with the login flow.
src/app/api/auth/child-login/route.ts (1)

31-37: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Use SESSION_TTL_SECONDS for child session expiry.
This route hard-codes 7 days while src/lib/auth.ts derives session expiry from SESSION_TTL_SECONDS; that can make the persisted session lifetime drift from the token/cookie lifetime. Reuse the shared constant here, or factor the session creation into a shared helper so all login paths stay aligned.

🤖 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 `@src/app/api/auth/child-login/route.ts` around lines 31 - 37, The child-login
session expiry is hard-coded to 7 days instead of using the shared
SESSION_TTL_SECONDS setting. Update the session creation in route.ts to derive
expires from SESSION_TTL_SECONDS like src/lib/auth.ts does, or move the
session-building logic into a shared helper used by the child login flow and
other login paths so the persisted session lifetime stays aligned with the
token/cookie lifetime.
🤖 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 `@src/app/api/admin/sessions/route.ts`:
- Around line 36-39: The single-session revoke path in the admin sessions
handler is too broad because revokeSession(sessionId) only checks the session
id, allowing this founder-only endpoint to revoke any known session. Update the
route in the sessions API so the revocation is scoped to the current user.id,
either by passing user.id into revokeSession or by adding an ownership check
before revoking, and keep the auditLog call unchanged once the session is
confirmed to belong to that founder.

In `@src/lib/auth-edge.ts`:
- Line 20: The cookie parsing in auth-edge’s guarded flow can throw before the
existing try/catch because `decodeURIComponent` is evaluated while building
`cookies` from `cookieHeader`. Move the
`cookieHeader.split(...).map(...decodeURIComponent...)` logic inside the
protected path’s try block in `getAuthData`/the auth middleware code so
malformed cookie values are handled as auth misses instead of bubbling up as
500s. Keep the same cookie extraction behavior, just ensure the decode step is
wrapped by the guard.

In `@src/lib/auth.ts`:
- Around line 199-223: The isSessionValid debounce cache never evicts successful
session entries, so lastSeenCache grows indefinitely in src/lib/auth.ts. Update
the cache management around LAST_SEEN_REFRESH_INTERVAL_MS and the
session.lastSeenAt refresh path so entries are removed after they are no longer
needed, not only on update failure. Use the existing isSessionValid and
lastSeenCache symbols to add expiration/cleanup for stale sessionId keys while
keeping the one-minute debounce behavior intact.
- Around line 164-170: The self-service session revocation in revokeSession
currently targets only session id, which lets a disclosed sessionId revoke
another user’s session. Update revokeSession in src/lib/auth.ts to include the
owning user identifier in the where filter (for example by accepting userId and
matching both id and userId) so only the session owner can revoke their own
session; if cross-user revocation is needed, keep that behavior in a separate
admin-only path used by the admin sessions route.

---

Outside diff comments:
In `@src/app/api/admin/security/route.ts`:
- Around line 91-105: The session check for email changes is happening after the
database transaction in the security admin route, so a missing/expired session
can still allow `tx.user.update` and `tx.systemSettings.update` to commit before
returning 401. Move the `getSession()`/`sessionId` validation ahead of the
`db.$transaction` inside the `route.ts` handler, gated by `updates.email`, and
only proceed to the transaction and `signSession` after confirming the current
session is valid.

In `@src/app/api/mutations/route.ts`:
- Around line 32-42: The POST handler in mutations/route.ts only validates that
getAuthUser() returns a session, but then dispatches any requested action
without role checks. Add per-action authorization in POST before the switch on
action, or pass the user context into the mutation helpers so setFamilySettings,
createParent, createChild, and giveTokens can enforce ownership and role-based
access. Keep the policy close to the action dispatch so only allowed actions for
the current user can execute.

---

Nitpick comments:
In `@src/app/api/auth/child-login/route.ts`:
- Around line 31-37: The child-login session expiry is hard-coded to 7 days
instead of using the shared SESSION_TTL_SECONDS setting. Update the session
creation in route.ts to derive expires from SESSION_TTL_SECONDS like
src/lib/auth.ts does, or move the session-building logic into a shared helper
used by the child login flow and other login paths so the persisted session
lifetime stays aligned with the token/cookie lifetime.

In `@src/app/api/auth/logout/route.ts`:
- Around line 7-16: The POST handler in logout/route.ts calls logout() without
passing request metadata, so logout audit entries miss IP and user agent. Update
the POST function to accept/use the incoming request object and extract the same
metadata already supported by logout() (ipAddress and userAgent), then include
those fields in the logout call alongside userId and sessionId. Keep the fix
localized to POST and the logout() invocation so logout logging stays consistent
with the login flow.
🪄 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 Plus

Run ID: 6515a431-5119-4180-ad95-a0af5aefcbe8

📥 Commits

Reviewing files that changed from the base of the PR and between 7053878 and 8a15b58.

📒 Files selected for processing (11)
  • prisma/migrations/20260625000000_add_session_revocation.sql
  • prisma/schema.prisma
  • src/app/api/admin/security/route.ts
  • src/app/api/admin/sessions/route.ts
  • src/app/api/auth/child-login/route.ts
  • src/app/api/auth/logout/route.ts
  • src/app/api/mutations/route.ts
  • src/app/api/state/route.ts
  • src/app/login/page.tsx
  • src/lib/auth-edge.ts
  • src/lib/auth.ts

Comment on lines +36 to +39
if (!sessionId) return NextResponse.json({ ok: false, error: "sessionId required (or all=true)" }, { status: 400 });
const ok = await revokeSession(sessionId);
if (!ok) return NextResponse.json({ ok: false, error: "Session not found or already revoked" }, { status: 404 });
await auditLog({ userId: user.id, action: "SESSION_REVOKED", entityType: "session", entityId: sessionId, ipAddress: ip, userAgent: ua, success: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Scope single-session revocation to the current founder.

Line 37 calls revokeSession(sessionId), whose upstream contract updates by session id only. That lets this founder-only endpoint revoke any known session id, not just one of user.id’s sessions.

🛡️ Proposed localized fix
   if (!sessionId) return NextResponse.json({ ok: false, error: "sessionId required (or all=true)" }, { status: 400 });
+  const ownsSession = (await listUserSessions(user.id)).some((session) => session.id === sessionId);
+  if (!ownsSession) return NextResponse.json({ ok: false, error: "Session not found" }, { status: 404 });
   const ok = await revokeSession(sessionId);
   if (!ok) return NextResponse.json({ ok: false, error: "Session not found or already revoked" }, { status: 404 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!sessionId) return NextResponse.json({ ok: false, error: "sessionId required (or all=true)" }, { status: 400 });
const ok = await revokeSession(sessionId);
if (!ok) return NextResponse.json({ ok: false, error: "Session not found or already revoked" }, { status: 404 });
await auditLog({ userId: user.id, action: "SESSION_REVOKED", entityType: "session", entityId: sessionId, ipAddress: ip, userAgent: ua, success: true });
if (!sessionId) return NextResponse.json({ ok: false, error: "sessionId required (or all=true)" }, { status: 400 });
const ownsSession = (await listUserSessions(user.id)).some((session) => session.id === sessionId);
if (!ownsSession) return NextResponse.json({ ok: false, error: "Session not found" }, { status: 404 });
const ok = await revokeSession(sessionId);
if (!ok) return NextResponse.json({ ok: false, error: "Session not found or already revoked" }, { status: 404 });
await auditLog({ userId: user.id, action: "SESSION_REVOKED", entityType: "session", entityId: sessionId, ipAddress: ip, userAgent: ua, success: true });
🤖 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 `@src/app/api/admin/sessions/route.ts` around lines 36 - 39, The single-session
revoke path in the admin sessions handler is too broad because
revokeSession(sessionId) only checks the session id, allowing this founder-only
endpoint to revoke any known session. Update the route in the sessions API so
the revocation is scoped to the current user.id, either by passing user.id into
revokeSession or by adding an ownership check before revoking, and keep the
auditLog call unchanged once the session is confirmed to belong to that founder.

Comment thread src/lib/auth-edge.ts
// getAuthUser() at the page/route level.
export async function getSessionFromCookieHeader(cookieHeader: string | null | undefined): Promise<SessionPayload | null> {
if (!cookieHeader) return null;
const cookies = Object.fromEntries(cookieHeader.split(";").map((c) => c.trim().split("=")).filter(([k]) => k).map(([k, ...rest]) => [k, decodeURIComponent(rest.join("="))]));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Parse the cookie header inside the guarded path.

Line 20 can throw before the try when decodeURIComponent sees a malformed cookie value. Because middleware calls this on every protected request, a bad Cookie header can turn an auth miss into a 500.

Proposed fix
 export async function getSessionFromCookieHeader(cookieHeader: string | null | undefined): Promise<SessionPayload | null> {
   if (!cookieHeader) return null;
-  const cookies = Object.fromEntries(cookieHeader.split(";").map((c) => c.trim().split("=")).filter(([k]) => k).map(([k, ...rest]) => [k, decodeURIComponent(rest.join("="))]));
-  const token = cookies[SESSION_COOKIE];
-  if (!token) return null;
   try {
+    const cookies = Object.fromEntries(cookieHeader.split(";").map((c) => c.trim().split("=")).filter(([k]) => k).map(([k, ...rest]) => [k, decodeURIComponent(rest.join("="))]));
+    const token = cookies[SESSION_COOKIE];
+    if (!token) return null;
     const { payload } = await jwtVerify(token, JWT_SECRET, { algorithms: ["HS256"] });
     if (typeof payload.sub !== "string" || typeof payload.email !== "string" || typeof payload.platformRole !== "string" || typeof payload.sessionId !== "string") return null;
     return { sub: payload.sub, email: payload.email, platformRole: payload.platformRole as string, familyRole: (payload.familyRole as string) || "PARENT", sessionId: payload.sessionId as string, iat: payload.iat, exp: payload.exp };
   } catch { return null; }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const cookies = Object.fromEntries(cookieHeader.split(";").map((c) => c.trim().split("=")).filter(([k]) => k).map(([k, ...rest]) => [k, decodeURIComponent(rest.join("="))]));
export async function getSessionFromCookieHeader(cookieHeader: string | null | undefined): Promise<SessionPayload | null> {
if (!cookieHeader) return null;
try {
const cookies = Object.fromEntries(cookieHeader.split(";").map((c) => c.trim().split("=")).filter(([k]) => k).map(([k, ...rest]) => [k, decodeURIComponent(rest.join("="))]));
const token = cookies[SESSION_COOKIE];
if (!token) return null;
const { payload } = await jwtVerify(token, JWT_SECRET, { algorithms: ["HS256"] });
if (typeof payload.sub !== "string" || typeof payload.email !== "string" || typeof payload.platformRole !== "string" || typeof payload.sessionId !== "string") return null;
return { sub: payload.sub, email: payload.email, platformRole: payload.platformRole as string, familyRole: (payload.familyRole as string) || "PARENT", sessionId: payload.sessionId as string, iat: payload.iat, exp: payload.exp };
} catch { return 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 `@src/lib/auth-edge.ts` at line 20, The cookie parsing in auth-edge’s guarded
flow can throw before the existing try/catch because `decodeURIComponent` is
evaluated while building `cookies` from `cookieHeader`. Move the
`cookieHeader.split(...).map(...decodeURIComponent...)` logic inside the
protected path’s try block in `getAuthData`/the auth middleware code so
malformed cookie values are handled as auth misses instead of bubbling up as
500s. Keep the same cookie extraction behavior, just ensure the decode step is
wrapped by the guard.

Comment thread src/lib/auth.ts
Comment on lines +164 to +170
export async function revokeSession(sessionId: string): Promise<boolean> {
if (!sessionId) return false;
try {
// Soft-revoke (set revokedAt). Keeps audit trail; allows admin to see history.
const result = await db.session.updateMany({
where: { id: sessionId, revokedAt: null },
data: { revokedAt: new Date() },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Scope single-session revocation to the session owner.

Lines 168-170 revoke by raw id only. In src/app/api/admin/sessions/route.ts:20-41, the caller passes a user-supplied sessionId while the corresponding list API is scoped to user.id, so this endpoint can revoke another user's session if its id is disclosed. Require userId in the where clause for the self-service path (or keep a separate explicitly-admin path if cross-user revocation is intentional).

Proposed fix
-export async function revokeSession(sessionId: string): Promise<boolean> {
+export async function revokeSession(sessionId: string, userId?: string): Promise<boolean> {
   if (!sessionId) return false;
   try {
     // Soft-revoke (set revokedAt). Keeps audit trail; allows admin to see history.
     const result = await db.session.updateMany({
-      where: { id: sessionId, revokedAt: null },
+      where: { id: sessionId, revokedAt: null, ...(userId ? { userId } : {}) },
       data: { revokedAt: new Date() },
     });
     return result.count > 0;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function revokeSession(sessionId: string): Promise<boolean> {
if (!sessionId) return false;
try {
// Soft-revoke (set revokedAt). Keeps audit trail; allows admin to see history.
const result = await db.session.updateMany({
where: { id: sessionId, revokedAt: null },
data: { revokedAt: new Date() },
export async function revokeSession(sessionId: string, userId?: string): Promise<boolean> {
if (!sessionId) return false;
try {
// Soft-revoke (set revokedAt). Keeps audit trail; allows admin to see history.
const result = await db.session.updateMany({
where: { id: sessionId, revokedAt: null, ...(userId ? { userId } : {}) },
data: { revokedAt: new Date() },
🤖 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 `@src/lib/auth.ts` around lines 164 - 170, The self-service session revocation
in revokeSession currently targets only session id, which lets a disclosed
sessionId revoke another user’s session. Update revokeSession in src/lib/auth.ts
to include the owning user identifier in the where filter (for example by
accepting userId and matching both id and userId) so only the session owner can
revoke their own session; if cross-user revocation is needed, keep that behavior
in a separate admin-only path used by the admin sessions route.

Comment thread src/lib/auth.ts
Comment on lines +199 to +223
const LAST_SEEN_REFRESH_INTERVAL_MS = 60 * 1000;
const lastSeenCache = new Map<string, number>();

async function isSessionValid(sessionId: string): Promise<boolean> {
if (!sessionId) return false;
try {
const session = await db.session.findUnique({ where: { id: sessionId } });
if (!session) return false;
if (session.revokedAt) return false;
if (session.expires < new Date()) return false;

// Debounced refresh of lastSeenAt — at most once per minute per session.
// This powers future admin UI ("last active 2 min ago") without DB churn.
const now = Date.now();
const lastRefresh = lastSeenCache.get(sessionId) ?? 0;
if (now - lastRefresh > LAST_SEEN_REFRESH_INTERVAL_MS) {
lastSeenCache.set(sessionId, now);
// Fire-and-forget — don't block the auth check on this write
db.session.update({
where: { id: sessionId },
data: { lastSeenAt: new Date() },
}).catch((err) => {
logger.error("Failed to refresh lastSeenAt", { err, sessionId });
lastSeenCache.delete(sessionId); // retry on next request
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Expire old lastSeenCache entries.

This cache only needs the last minute of history, but successful sessions never evict their key. On a long-lived process, the Map grows with total historical logins rather than currently active debounce windows.

Proposed fix
 const LAST_SEEN_REFRESH_INTERVAL_MS = 60 * 1000;
 const lastSeenCache = new Map<string, number>();
+
+function pruneLastSeenCache(now: number): void {
+  for (const [id, ts] of lastSeenCache) {
+    if (now - ts > LAST_SEEN_REFRESH_INTERVAL_MS) lastSeenCache.delete(id);
+  }
+}
 
 async function isSessionValid(sessionId: string): Promise<boolean> {
   if (!sessionId) return false;
   try {
     const session = await db.session.findUnique({ where: { id: sessionId } });
-    if (!session) return false;
-    if (session.revokedAt) return false;
-    if (session.expires < new Date()) return false;
+    if (!session || session.revokedAt || session.expires < new Date()) {
+      lastSeenCache.delete(sessionId);
+      return false;
+    }
 
     // Debounced refresh of lastSeenAt — at most once per minute per session.
     // This powers future admin UI ("last active 2 min ago") without DB churn.
     const now = Date.now();
+    pruneLastSeenCache(now);
     const lastRefresh = lastSeenCache.get(sessionId) ?? 0;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const LAST_SEEN_REFRESH_INTERVAL_MS = 60 * 1000;
const lastSeenCache = new Map<string, number>();
async function isSessionValid(sessionId: string): Promise<boolean> {
if (!sessionId) return false;
try {
const session = await db.session.findUnique({ where: { id: sessionId } });
if (!session) return false;
if (session.revokedAt) return false;
if (session.expires < new Date()) return false;
// Debounced refresh of lastSeenAt — at most once per minute per session.
// This powers future admin UI ("last active 2 min ago") without DB churn.
const now = Date.now();
const lastRefresh = lastSeenCache.get(sessionId) ?? 0;
if (now - lastRefresh > LAST_SEEN_REFRESH_INTERVAL_MS) {
lastSeenCache.set(sessionId, now);
// Fire-and-forget — don't block the auth check on this write
db.session.update({
where: { id: sessionId },
data: { lastSeenAt: new Date() },
}).catch((err) => {
logger.error("Failed to refresh lastSeenAt", { err, sessionId });
lastSeenCache.delete(sessionId); // retry on next request
});
const LAST_SEEN_REFRESH_INTERVAL_MS = 60 * 1000;
const lastSeenCache = new Map<string, number>();
function pruneLastSeenCache(now: number): void {
for (const [id, ts] of lastSeenCache) {
if (now - ts > LAST_SEEN_REFRESH_INTERVAL_MS) lastSeenCache.delete(id);
}
}
async function isSessionValid(sessionId: string): Promise<boolean> {
if (!sessionId) return false;
try {
const session = await db.session.findUnique({ where: { id: sessionId } });
if (!session || session.revokedAt || session.expires < new Date()) {
lastSeenCache.delete(sessionId);
return false;
}
// Debounced refresh of lastSeenAt — at most once per minute per session.
// This powers future admin UI ("last active 2 min ago") without DB churn.
const now = Date.now();
pruneLastSeenCache(now);
const lastRefresh = lastSeenCache.get(sessionId) ?? 0;
🤖 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 `@src/lib/auth.ts` around lines 199 - 223, The isSessionValid debounce cache
never evicts successful session entries, so lastSeenCache grows indefinitely in
src/lib/auth.ts. Update the cache management around
LAST_SEEN_REFRESH_INTERVAL_MS and the session.lastSeenAt refresh path so entries
are removed after they are no longer needed, not only on update failure. Use the
existing isSessionValid and lastSeenCache symbols to add expiration/cleanup for
stale sessionId keys while keeping the one-minute debounce behavior intact.

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.

1 participant