From 6af2ac96809f6e5fea472eac5b4ec33776431bac Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Sat, 2 May 2026 23:00:42 +0200 Subject: [PATCH] fix(auth): harden admin gate, X-Port-Id, portal JWT, saved-views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add server-side `/layout.tsx` that redirects non-super-admins to `/[portSlug]/dashboard`. Closes the gap where any authed user could guess the URL and reach Users / Roles / Audit Log / Backup. - `withAuth` super-admin branch now 404s when the requested portId does not match a real port row, preventing a compromised super-admin session from operating against a fabricated portId. - Portal JWTs now carry `aud: 'portal'` + `iss: 'pn-crm'` claims and `verifyPortalToken` requires both, so a portal token can no longer be replayed against the CRM session path or vice versa. In-flight tokens (≤24h) will be invalidated once on deploy. - `saved-views/[id]` PATCH and DELETE now do an explicit ownership check before the service call, returning 403 instead of relying on the service's internal userId filter. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../(dashboard)/[portSlug]/admin/layout.tsx | 36 +++++++++++++++++++ src/app/api/v1/saved-views/[id]/route.ts | 25 +++++++++++++ src/lib/api/helpers.ts | 7 +++- src/lib/portal/auth.ts | 13 ++++++- 4 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 src/app/(dashboard)/[portSlug]/admin/layout.tsx diff --git a/src/app/(dashboard)/[portSlug]/admin/layout.tsx b/src/app/(dashboard)/[portSlug]/admin/layout.tsx new file mode 100644 index 0000000..8234598 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/admin/layout.tsx @@ -0,0 +1,36 @@ +import { redirect } from 'next/navigation'; +import { headers } from 'next/headers'; +import { eq } from 'drizzle-orm'; + +import { auth } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { userProfiles } from '@/lib/db/schema/users'; + +/** + * Guard: only super-admins (isSuperAdmin === true in user_profiles) may access + * any page under /[portSlug]/admin. Everyone else is redirected to their dashboard. + */ +export default async function AdminLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ portSlug: string }>; +}) { + const { portSlug } = await params; + const session = await auth.api.getSession({ headers: await headers() }); + + if (!session?.user) { + redirect('/login'); + } + + const profile = await db.query.userProfiles.findFirst({ + where: eq(userProfiles.userId, session.user.id), + }); + + if (!profile?.isSuperAdmin) { + redirect(`/${portSlug}/dashboard`); + } + + return <>{children}; +} diff --git a/src/app/api/v1/saved-views/[id]/route.ts b/src/app/api/v1/saved-views/[id]/route.ts index ffd7f1a..6c76af4 100644 --- a/src/app/api/v1/saved-views/[id]/route.ts +++ b/src/app/api/v1/saved-views/[id]/route.ts @@ -1,14 +1,37 @@ +import { and, eq } from 'drizzle-orm'; import { NextResponse } from 'next/server'; import { withAuth } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; +import { db } from '@/lib/db'; +import { savedViews } from '@/lib/db/schema'; import { errorResponse } from '@/lib/errors'; import { savedViewsService } from '@/lib/services/saved-views.service'; import { updateSavedViewSchema } from '@/lib/validators/saved-views'; +/** Resolves the view and enforces ownership before mutating. */ +async function assertViewOwner( + id: string, + portId: string, + userId: string, +): Promise { + const view = await db.query.savedViews.findFirst({ + where: and(eq(savedViews.id, id), eq(savedViews.portId, portId)), + }); + if (!view) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + if (view.userId !== userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + return null; +} + export const PATCH = withAuth(async (req, ctx, params) => { try { const id = params.id ?? ''; + const denied = await assertViewOwner(id, ctx.portId, ctx.userId); + if (denied) return denied; const body = await parseBody(req, updateSavedViewSchema); const view = await savedViewsService.update(ctx.portId, ctx.userId, id, body); return NextResponse.json({ data: view }); @@ -20,6 +43,8 @@ export const PATCH = withAuth(async (req, ctx, params) => { export const DELETE = withAuth(async (_req, ctx, params) => { try { const id = params.id ?? ''; + const denied = await assertViewOwner(id, ctx.portId, ctx.userId); + if (denied) return denied; await savedViewsService.delete(ctx.portId, ctx.userId, id); return NextResponse.json({ data: null }, { status: 200 }); } catch (error) { diff --git a/src/lib/api/helpers.ts b/src/lib/api/helpers.ts index a5a12c0..221c3b5 100644 --- a/src/lib/api/helpers.ts +++ b/src/lib/api/helpers.ts @@ -181,10 +181,15 @@ export function withAuth( } } else if (profile.isSuperAdmin && portId) { // Super admin still needs portSlug for response context. + // We also validate the portId actually exists — a super-admin session + // must not be able to operate against a fabricated portId. const port = await db.query.ports.findFirst({ where: eq(ports.id, portId), }); - portSlug = port?.slug ?? ''; + if (!port) { + return NextResponse.json({ error: 'Port not found' }, { status: 404 }); + } + portSlug = port.slug; } const ctx: AuthContext = { diff --git a/src/lib/portal/auth.ts b/src/lib/portal/auth.ts index 22dcc15..017b9d2 100644 --- a/src/lib/portal/auth.ts +++ b/src/lib/portal/auth.ts @@ -4,6 +4,12 @@ import { cookies } from 'next/headers'; const PORTAL_SECRET = new TextEncoder().encode(process.env.BETTER_AUTH_SECRET); export const PORTAL_COOKIE = 'portal_session'; +// BREAKING CHANGE (intentional): tokens issued before this change lack aud/iss +// and will be rejected by verifyPortalToken. Portal tokens are 24h-lived so +// existing sessions will be invalidated on deploy. Users simply re-login. +const PORTAL_AUD = 'portal'; +const PORTAL_ISS = 'pn-crm'; + export interface PortalSession { clientId: string; portId: string; @@ -13,6 +19,8 @@ export interface PortalSession { export async function createPortalToken(session: PortalSession): Promise { return new SignJWT(session as unknown as Record) .setProtectedHeader({ alg: 'HS256' }) + .setAudience(PORTAL_AUD) + .setIssuer(PORTAL_ISS) .setExpirationTime('24h') .setIssuedAt() .sign(PORTAL_SECRET); @@ -20,7 +28,10 @@ export async function createPortalToken(session: PortalSession): Promise export async function verifyPortalToken(token: string): Promise { try { - const { payload } = await jwtVerify(token, PORTAL_SECRET); + const { payload } = await jwtVerify(token, PORTAL_SECRET, { + audience: PORTAL_AUD, + issuer: PORTAL_ISS, + }); return payload as unknown as PortalSession; } catch { return null;