1
0
Fork 0

fix(auth): reject MCP API-key sessions in user auth paths

With the MCP plugin enabled, payload.auth() can return a
payload-mcp-api-keys doc instead of a User. Add an isPayloadUser
type guard and use it in every server action, page, and route that
treats the auth result as a real user, so API-key sessions can no
longer pass user auth checks. hasRoles now explicitly requires a
users-collection doc.
This commit is contained in:
Jason Fraley 2026-08-25 12:27:35 -04:00
parent b70ff4dff8
commit c0d00fc113
13 changed files with 40 additions and 15 deletions

View file

@ -1,6 +1,7 @@
"use server"; "use server";
import config from "@payload-config"; import config from "@payload-config";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
@ -23,7 +24,7 @@ async function authenticate() {
const hdrs = await headers(); const hdrs = await headers();
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false }); const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
if (!user) { if (!isPayloadUser(user)) {
throw new Error("Unauthorized"); throw new Error("Unauthorized");
} }

View file

@ -1,6 +1,7 @@
"use server"; "use server";
import config from "@payload-config"; import config from "@payload-config";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { Ticket } from "@/payload-types"; import type { Ticket } from "@/payload-types";
import { hasPermission } from "@/utils/access-control/hasPermission"; import { hasPermission } from "@/utils/access-control/hasPermission";
@ -25,7 +26,7 @@ async function authenticate() {
const hdrs = await headers(); const hdrs = await headers();
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false }); const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
if (!user) { if (!isPayloadUser(user)) {
throw new Error("Unauthorized"); throw new Error("Unauthorized");
} }

View file

@ -4,6 +4,7 @@ import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/frontend/blocks/AppSidebar"; import { AppSidebar } from "@/components/frontend/blocks/AppSidebar";
import { getPayload } from "payload"; import { getPayload } from "payload";
import config from "@payload-config"; import config from "@payload-config";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import { SiteHeader } from "@/components/frontend/SiteHeader"; import { SiteHeader } from "@/components/frontend/SiteHeader";
import { Metadata } from "next"; import { Metadata } from "next";
@ -50,7 +51,8 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
const payloadConfig = await config; const payloadConfig = await config;
const payload = await getPayload({ config }); const payload = await getPayload({ config });
const { user } = await payload.auth({ headers, canSetHeaders: false }); const { user: authUser } = await payload.auth({ headers, canSetHeaders: false });
const user = isPayloadUser(authUser) ? authUser : null;
const impersonating = (await cookies()).has(IMPERSONATION_ACTIVE_COOKIE); const impersonating = (await cookies()).has(IMPERSONATION_ACTIVE_COOKIE);
let xpLevel: ResolvedLevel = { let xpLevel: ResolvedLevel = {

View file

@ -1,6 +1,7 @@
"use server"; "use server";
import config from "@payload-config"; import config from "@payload-config";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { Asset, Loadout, LockerStorage, User } from "@/payload-types"; import type { Asset, Loadout, LockerStorage, User } from "@/payload-types";
import { hasPermission } from "@/utils/access-control/hasPermission"; import { hasPermission } from "@/utils/access-control/hasPermission";
@ -39,7 +40,7 @@ async function authenticate() {
const hdrs = await headers(); const hdrs = await headers();
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false }); const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
if (!user) { if (!isPayloadUser(user)) {
throw new Error("Unauthorized"); throw new Error("Unauthorized");
} }

View file

@ -1,4 +1,5 @@
import config from "@payload-config"; import config from "@payload-config";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import type { Asset, Loadout, LockerStorage } from "@/payload-types"; import type { Asset, Loadout, LockerStorage } from "@/payload-types";
@ -13,10 +14,11 @@ export const metadata = {
export default async function LockerPage() { export default async function LockerPage() {
const payload = await getPayload({ config }); const payload = await getPayload({ config });
const { user } = await payload.auth({ const { user: authUser } = await payload.auth({
headers: await nextHeaders(), headers: await nextHeaders(),
canSetHeaders: false, canSetHeaders: false,
}); });
const user = isPayloadUser(authUser) ? authUser : null;
const userId = user?.id as number; const userId = user?.id as number;
const locker = await ensureLockerStorage(payload, userId); const locker = await ensureLockerStorage(payload, userId);

View file

@ -1,6 +1,7 @@
"use server"; "use server";
import config from "@payload-config"; import config from "@payload-config";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { User } from "@/payload-types"; import type { User } from "@/payload-types";
import { hasPermission } from "@/utils/access-control/hasPermission"; import { hasPermission } from "@/utils/access-control/hasPermission";
@ -31,7 +32,7 @@ async function authenticate() {
canSetHeaders: false, canSetHeaders: false,
}); });
if (!user) { if (!isPayloadUser(user)) {
throw new Error("Unauthorized"); throw new Error("Unauthorized");
} }

View file

@ -1,4 +1,5 @@
import config from "@payload-config"; import config from "@payload-config";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import type { BankAccount, BankTransaction, Faction, Resource } from "@/payload-types"; import type { BankAccount, BankTransaction, Faction, Resource } from "@/payload-types";
@ -13,10 +14,11 @@ export const metadata = {
export default async function BankingPage() { export default async function BankingPage() {
const payload = await getPayload({ config }); const payload = await getPayload({ config });
const { user } = await payload.auth({ const { user: authUser } = await payload.auth({
headers: await nextHeaders(), headers: await nextHeaders(),
canSetHeaders: false, canSetHeaders: false,
}); });
const user = isPayloadUser(authUser) ? authUser : null;
const accountsRes = await payload.find({ const accountsRes = await payload.find({
collection: "bank-accounts", collection: "bank-accounts",

View file

@ -1,6 +1,7 @@
"use server"; "use server";
import config from "@payload-config"; import config from "@payload-config";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { Asset, LockerStorage, MarketListing, MarketNegotiation, User } from "@/payload-types"; import type { Asset, LockerStorage, MarketListing, MarketNegotiation, User } from "@/payload-types";
import { hasPermission } from "@/utils/access-control/hasPermission"; import { hasPermission } from "@/utils/access-control/hasPermission";
@ -37,7 +38,7 @@ async function authenticate() {
const hdrs = await headers(); const hdrs = await headers();
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false }); const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
if (!user) { if (!isPayloadUser(user)) {
throw new Error("Unauthorized"); throw new Error("Unauthorized");
} }

View file

@ -1,4 +1,5 @@
import config from "@payload-config"; import config from "@payload-config";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import type { Media, Rank, User } from "@/payload-types"; import type { Media, Rank, User } from "@/payload-types";
@ -34,7 +35,8 @@ export type RosterAssignment = {
export default async function RosterPage() { export default async function RosterPage() {
const headers = await nextHeaders(); const headers = await nextHeaders();
const payload = await getPayload({ config }); const payload = await getPayload({ config });
const { user: currentUser } = await payload.auth({ headers, canSetHeaders: false }); const { user: authUser } = await payload.auth({ headers, canSetHeaders: false });
const currentUser = isPayloadUser(authUser) ? authUser : null;
const [profileRes, assignmentRes] = await Promise.all([ const [profileRes, assignmentRes] = await Promise.all([
payload.find({ payload.find({

View file

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import config from "@payload-config"; import config from "@payload-config";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { removePresence, touchPresence } from "@/lib/realtime/presence"; import { removePresence, touchPresence } from "@/lib/realtime/presence";
@ -20,7 +21,7 @@ export async function POST(req: NextRequest) {
const channel = channelFrom(req); const channel = channelFrom(req);
if (!channel) return NextResponse.json({ ok: false, error: "Invalid channel." }, { status: 400 }); if (!channel) return NextResponse.json({ ok: false, error: "Invalid channel." }, { status: 400 });
const { user } = await authenticate(req); const { user } = await authenticate(req);
if (!user) return NextResponse.json({ ok: false }, { status: 401 }); if (!isPayloadUser(user)) return NextResponse.json({ ok: false }, { status: 401 });
touchPresence(channel, { touchPresence(channel, {
id: user.id as number, id: user.id as number,
name: user.payloadDisplayName || user.username || `Pilot #${user.id}`, name: user.payloadDisplayName || user.username || `Pilot #${user.id}`,
@ -32,7 +33,7 @@ export async function DELETE(req: NextRequest) {
const channel = channelFrom(req); const channel = channelFrom(req);
if (!channel) return NextResponse.json({ ok: false, error: "Invalid channel." }, { status: 400 }); if (!channel) return NextResponse.json({ ok: false, error: "Invalid channel." }, { status: 400 });
const { user } = await authenticate(req); const { user } = await authenticate(req);
if (!user) return NextResponse.json({ ok: false }, { status: 401 }); if (!isPayloadUser(user)) return NextResponse.json({ ok: false }, { status: 401 });
removePresence(channel, user.id as number); removePresence(channel, user.id as number);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }

View file

@ -1,5 +1,6 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { requirePermission } from "@/utils/access-control/hasPermission"; import { requirePermission } from "@/utils/access-control/hasPermission";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
export const Technologies: CollectionConfig = { export const Technologies: CollectionConfig = {
slug: "technologies", slug: "technologies",
@ -32,7 +33,9 @@ export const Technologies: CollectionConfig = {
], ],
defaultValue: "in_progress", defaultValue: "in_progress",
filterOptions: ({ options, req }) => { filterOptions: ({ options, req }) => {
const roles = (req.user?.roles as string[] | undefined) ?? []; const roles = isPayloadUser(req.user)
? ((req.user.roles as string[] | undefined) ?? [])
: [];
const canReadAll = roles.includes("admin") || roles.includes("developer"); const canReadAll = roles.includes("admin") || roles.includes("developer");
if (canReadAll) return options; if (canReadAll) return options;
return options.filter((option) => return options.filter((option) =>

View file

@ -1,7 +1,10 @@
import { User } from "@/payload-types"; import { PayloadMcpApiKey, User } from "@/payload-types";
export default function hasRoles(roles: NonNullable<User["roles"]>, user: User | null | undefined) { export default function hasRoles(
if (!user) return false; roles: NonNullable<User["roles"]>,
user: User | PayloadMcpApiKey | null | undefined,
) {
if (!user || user.collection !== "users") return false;
return roles.some((role) => user.roles?.some((urole) => urole === role || urole === "developer")); return roles.some((role) => user.roles?.some((urole) => urole === role || urole === "developer"));
} }

View file

@ -0,0 +1,5 @@
import type { PayloadMcpApiKey, User } from "@/payload-types";
export function isPayloadUser(user: User | PayloadMcpApiKey | null | undefined): user is User {
return !!user && user.collection === "users";
}