diff --git a/packages/blog/src/integrations/api/procedures.ts b/packages/blog/src/integrations/api/procedures.ts index 50974e1..b8ee781 100644 --- a/packages/blog/src/integrations/api/procedures.ts +++ b/packages/blog/src/integrations/api/procedures.ts @@ -1,5 +1,6 @@ import { t } from "@repo/core-shared/trpc/init"; import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; +import { requireAuthenticated } from "@repo/core-shared/trpc/require-authenticated"; import { ArticleNotFoundError } from "../../entities/errors/article"; import { InputParseError } from "../../entities/errors/common"; @@ -10,3 +11,10 @@ export const blogProcedure = t.procedure.use( [ArticleNotFoundError, "NOT_FOUND"], ]), ); + +/** + * Base procedure for MUTATING blog routes (audit finding B7): anonymous + * callers are rejected with UNAUTHORIZED before the controller runs. + * Read-only queries stay on `blogProcedure`. + */ +export const blogProtectedProcedure = blogProcedure.use(requireAuthenticated); diff --git a/packages/blog/src/integrations/api/router.test.ts b/packages/blog/src/integrations/api/router.test.ts index c791716..9eb63bc 100644 --- a/packages/blog/src/integrations/api/router.test.ts +++ b/packages/blog/src/integrations/api/router.test.ts @@ -30,7 +30,10 @@ describe("blogRouter", () => { }); it("createArticle then articleBySlug returns the article", async () => { - const caller = blogRouter.createCaller({}); + // Mutations are auth-gated (B7) — provide a server-resolved ctx.user. + const caller = blogRouter.createCaller({ + user: { id: "u1", roles: [] }, + }); const created = await caller.createArticle({ title: "Router Test Article", @@ -45,6 +48,34 @@ describe("blogRouter", () => { }); }); +describe("blogRouter authorization (B7)", () => { + beforeEach(() => { + blogContainer.unbindAll(); + blogContainer.load(BlogModule); + }); + + afterEach(() => { + blogContainer.unbindAll(); + }); + + it("createArticle rejects anonymous callers with UNAUTHORIZED", async () => { + const caller = blogRouter.createCaller({}); + await expect( + caller.createArticle({ + title: "Nope", + content: null, + authorId: "u1", + slug: "nope", + }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); + + it("read-only queries stay public", async () => { + const caller = blogRouter.createCaller({}); + await expect(caller.listArticles({})).resolves.toEqual([]); + }); +}); + describe("blogRouter error mapping", () => { beforeEach(() => { blogContainer.unbindAll(); diff --git a/packages/blog/src/integrations/api/router.ts b/packages/blog/src/integrations/api/router.ts index af74435..077017a 100644 --- a/packages/blog/src/integrations/api/router.ts +++ b/packages/blog/src/integrations/api/router.ts @@ -11,7 +11,7 @@ import type { IGetArticlesController } from "../../interface-adapters/controller import type { ICreateArticleController } from "../../interface-adapters/controllers/create-article.controller"; import type { IGetArticleBySlugController } from "../../interface-adapters/controllers/get-article-by-slug.controller"; -import { blogProcedure } from "./procedures"; +import { blogProcedure, blogProtectedProcedure } from "./procedures"; export const blogRouter = router({ articleBySlug: blogProcedure @@ -32,7 +32,8 @@ export const blogRouter = router({ return ctrl(input); }), - createArticle: blogProcedure + // Mutations require an authenticated caller (B7). + createArticle: blogProtectedProcedure .input(createArticleInputSchema) .mutation(({ input }) => { const ctrl = blogContainer.get( diff --git a/packages/core-shared/package.json b/packages/core-shared/package.json index 81f39f3..9baf51d 100644 --- a/packages/core-shared/package.json +++ b/packages/core-shared/package.json @@ -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", diff --git a/packages/core-shared/src/trpc/require-authenticated.test.ts b/packages/core-shared/src/trpc/require-authenticated.test.ts new file mode 100644 index 0000000..e7d3993 --- /dev/null +++ b/packages/core-shared/src/trpc/require-authenticated.test.ts @@ -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"); + }); +}); diff --git a/packages/core-shared/src/trpc/require-authenticated.ts b/packages/core-shared/src/trpc/require-authenticated.ts new file mode 100644 index 0000000..c9fc888 --- /dev/null +++ b/packages/core-shared/src/trpc/require-authenticated.ts @@ -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); diff --git a/packages/media/src/integrations/api/procedures.ts b/packages/media/src/integrations/api/procedures.ts index 98b3490..ea5244d 100644 --- a/packages/media/src/integrations/api/procedures.ts +++ b/packages/media/src/integrations/api/procedures.ts @@ -1,5 +1,6 @@ import { t } from "@repo/core-shared/trpc/init"; import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; +import { requireAuthenticated } from "@repo/core-shared/trpc/require-authenticated"; import { MediaNotFoundError } from "../../entities/errors/media"; import { InputParseError } from "../../entities/errors/common"; @@ -10,3 +11,10 @@ export const mediaProcedure = t.procedure.use( [MediaNotFoundError, "NOT_FOUND"], ]), ); + +/** + * Base procedure for MUTATING media routes (audit finding B7): anonymous + * callers are rejected with UNAUTHORIZED before the controller runs. + * Read-only queries stay on `mediaProcedure`. + */ +export const mediaProtectedProcedure = mediaProcedure.use(requireAuthenticated); diff --git a/packages/media/src/integrations/api/router.test.ts b/packages/media/src/integrations/api/router.test.ts index 348258d..fc91b00 100644 --- a/packages/media/src/integrations/api/router.test.ts +++ b/packages/media/src/integrations/api/router.test.ts @@ -36,6 +36,29 @@ describe("mediaRouter", () => { }); }); +describe("mediaRouter authorization (B7)", () => { + beforeEach(() => { + mediaContainer.unbindAll(); + mediaContainer.load(MediaModule); + }); + + afterEach(() => { + mediaContainer.unbindAll(); + }); + + it("deleteMedia rejects anonymous callers with UNAUTHORIZED", async () => { + const caller = mediaRouter.createCaller({}); + await expect(caller.deleteMedia({ id: "some-id" })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + }); + + it("read-only queries stay public", async () => { + const caller = mediaRouter.createCaller({}); + await expect(caller.listMedia({})).resolves.toEqual([]); + }); +}); + describe("mediaRouter error mapping", () => { beforeEach(() => { mediaContainer.unbindAll(); @@ -69,7 +92,8 @@ describe("mediaRouter error mapping", () => { }); it("translates MediaNotFoundError → NOT_FOUND on deleteMedia with missing id", async () => { - const caller = mediaRouter.createCaller({}); + // Mutations are auth-gated (B7) — provide a server-resolved ctx.user. + const caller = mediaRouter.createCaller({ user: { id: "u1", roles: [] } }); try { await caller.deleteMedia({ id: "nonexistent-id" }); throw new Error("expected throw"); diff --git a/packages/media/src/integrations/api/router.ts b/packages/media/src/integrations/api/router.ts index c7a3608..0e1214a 100644 --- a/packages/media/src/integrations/api/router.ts +++ b/packages/media/src/integrations/api/router.ts @@ -11,21 +11,30 @@ import type { IGetMediaController } from "../../interface-adapters/controllers/g import type { IListMediaController } from "../../interface-adapters/controllers/list-media.controller"; import type { IDeleteMediaController } from "../../interface-adapters/controllers/delete-media.controller"; -import { mediaProcedure } from "./procedures"; +import { mediaProcedure, mediaProtectedProcedure } from "./procedures"; export const mediaRouter = router({ getMedia: mediaProcedure.input(getMediaInputSchema).query(({ input }) => { - const ctrl = mediaContainer.get(MEDIA_SYMBOLS.IGetMediaController); + const ctrl = mediaContainer.get( + MEDIA_SYMBOLS.IGetMediaController, + ); return ctrl(input); }), listMedia: mediaProcedure.input(listMediaInputSchema).query(({ input }) => { - const ctrl = mediaContainer.get(MEDIA_SYMBOLS.IListMediaController); - return ctrl(input); - }), - deleteMedia: mediaProcedure.input(deleteMediaInputSchema).mutation(({ input }) => { - const ctrl = mediaContainer.get(MEDIA_SYMBOLS.IDeleteMediaController); + const ctrl = mediaContainer.get( + MEDIA_SYMBOLS.IListMediaController, + ); return ctrl(input); }), + // Mutations require an authenticated caller (B7). + deleteMedia: mediaProtectedProcedure + .input(deleteMediaInputSchema) + .mutation(({ input }) => { + const ctrl = mediaContainer.get( + MEDIA_SYMBOLS.IDeleteMediaController, + ); + return ctrl(input); + }), }); export type MediaRouter = typeof mediaRouter;