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:
parent
b70ff4dff8
commit
c0d00fc113
13 changed files with 40 additions and 15 deletions
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
5
src/utils/access-control/isPayloadUser.ts
Normal file
5
src/utils/access-control/isPayloadUser.ts
Normal 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";
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue