fix(auth): harden admin gate, X-Port-Id, portal JWT, saved-views
- Add server-side `<admin>/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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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<string> {
|
||||
return new SignJWT(session as unknown as Record<string, unknown>)
|
||||
.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<string>
|
||||
|
||||
export async function verifyPortalToken(token: string): Promise<PortalSession | null> {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user