feat(core-shared): auth-gate mutating feature procedures
Adds a shared requireAuthenticated tRPC middleware (reads the server- resolved ctx.user from createTrpcContext) and applies it to every mutating feature procedure — blog.createArticle and media.deleteMedia were anonymous-callable (audit finding B7). Read-only queries stay public; features compose <x>ProtectedProcedure from their error-mapped base procedure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
"./payload": "./src/payload/index.ts",
|
||||
"./trpc/init": "./src/trpc/init.ts",
|
||||
"./trpc/context": "./src/trpc/context.ts",
|
||||
"./trpc/require-authenticated": "./src/trpc/require-authenticated.ts",
|
||||
"./trpc/define-error-middleware": "./src/trpc/define-error-middleware.ts",
|
||||
"./instrumentation": "./src/instrumentation/index.ts",
|
||||
"./instrumentation/otel": "./src/instrumentation/otel/index.ts",
|
||||
|
||||
50
packages/core-shared/src/trpc/require-authenticated.test.ts
Normal file
50
packages/core-shared/src/trpc/require-authenticated.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { t } from "@/trpc/init";
|
||||
import {
|
||||
requireAuthenticated,
|
||||
protectedProcedure,
|
||||
} from "@/trpc/require-authenticated";
|
||||
|
||||
const echoRouter = t.router({
|
||||
publicEcho: t.procedure
|
||||
.input(z.object({ value: z.string() }).strict())
|
||||
.query(({ input }) => input.value),
|
||||
protectedEcho: protectedProcedure
|
||||
.input(z.object({ value: z.string() }).strict())
|
||||
.mutation(({ input, ctx }) => ({
|
||||
value: input.value,
|
||||
userId: (ctx as { user: { id: string } }).user.id,
|
||||
})),
|
||||
composedEcho: t.procedure
|
||||
.use(requireAuthenticated)
|
||||
.input(z.object({}).strict())
|
||||
.mutation(() => "ok"),
|
||||
});
|
||||
|
||||
describe("requireAuthenticated middleware (B7)", () => {
|
||||
it("rejects anonymous callers with UNAUTHORIZED", async () => {
|
||||
const caller = echoRouter.createCaller({});
|
||||
await expect(caller.protectedEcho({ value: "x" })).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
await expect(caller.composedEcho({})).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes through authenticated callers and exposes ctx.user", async () => {
|
||||
const caller = echoRouter.createCaller({
|
||||
user: { id: "user-1", roles: [] },
|
||||
});
|
||||
await expect(caller.protectedEcho({ value: "x" })).resolves.toEqual({
|
||||
value: "x",
|
||||
userId: "user-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves public procedures untouched", async () => {
|
||||
const caller = echoRouter.createCaller({});
|
||||
await expect(caller.publicEcho({ value: "hi" })).resolves.toBe("hi");
|
||||
});
|
||||
});
|
||||
32
packages/core-shared/src/trpc/require-authenticated.ts
Normal file
32
packages/core-shared/src/trpc/require-authenticated.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { t } from "./init";
|
||||
import type { TrpcSessionUser } from "./context";
|
||||
|
||||
/**
|
||||
* Shared authentication guard for MUTATING tRPC procedures (audit finding
|
||||
* B7). Reads the server-resolved `ctx.user` (attached by `createTrpcContext`
|
||||
* via the app's `resolveUser`) and rejects anonymous callers with
|
||||
* UNAUTHORIZED. Read-only queries stay public; each feature opts its
|
||||
* mutations in by composing this middleware into its procedure chain:
|
||||
*
|
||||
* ```ts
|
||||
* export const blogProtectedProcedure = blogProcedure.use(requireAuthenticated);
|
||||
* ```
|
||||
*
|
||||
* The shared `t` is context-untyped, so the middleware narrows at runtime
|
||||
* (same cast pattern as the dsr/audit routers) and re-publishes `user` into
|
||||
* the downstream ctx with a non-optional type.
|
||||
*/
|
||||
export const requireAuthenticated = t.middleware(({ ctx, next }) => {
|
||||
const user = (ctx as { user?: TrpcSessionUser }).user;
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Authentication required",
|
||||
});
|
||||
}
|
||||
return next({ ctx: { ...ctx, user } });
|
||||
});
|
||||
|
||||
/** Convenience base procedure for apps composing ad-hoc protected routes. */
|
||||
export const protectedProcedure = t.procedure.use(requireAuthenticated);
|
||||
Reference in New Issue
Block a user