UNPKG

@rsc-kit/mcp

Version:

An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.

361 lines (263 loc) • 11.8 kB
# Authorization > Protecting pages, server actions and API routes. There are three ways into your app — a page, a server action, an API route — and each needs its own check. Guarding one does not guard the others. ## Protect a page Put a `middleware.ts` in the directory you want to protect: ```ts title="src/app/admin/middleware.ts" import { redirect } from '@rsc-kit/core/redirect'; import { currentUser } from '../../auth'; export default async function middleware() { const user = await currentUser(); if (!user?.isAdmin) redirect('/login'); } ``` It runs before anything in that directory or below it renders. Return nothing to allow; redirect or throw to refuse. Middleware compose up the tree like layouts — outermost first — and **every** request runs the whole chain: a full page load, a navigation, a prefetch, a revalidation. There is no flag to remember; the file is the declaration. Several checks in one directory are a list, run in the order written and stopping at the first refusal — which is how a check written once is reused where it is needed: ```ts title="src/app/admin/middleware.ts" import { signedIn, verified } from '../../guards'; import { redirect } from '@rsc-kit/core/redirect'; export default [ signedIn, verified, async () => { if (!(await currentUser())?.isAdmin) redirect('/'); }, ]; ``` `src/app/middleware.ts` above it still runs first; the list is this directory's own part of the chain. A guard can also say the page is not there. `notFound()` in middleware.ts answers **404** with `not-found.tsx`, before anything is rendered — for an admin area that should not admit to existing, rather than sending strangers somewhere else: ```ts title="src/app/admin/middleware.ts" import { notFound } from '@rsc-kit/core/not-found'; export default async function adminsOnly() { if (!(await currentUser())?.isAdmin) notFound(); } ``` <Aside type="caution" title="Do not put the check in a layout"> It looks like it works, and it does on a full page load. But a navigation tells the server which layouts the browser already has, and the server skips them — that is what makes navigation fast. A request can claim to have your layout, and then the check never runs. `middleware.ts` is never skipped. </Aside> ## Protect a server action An action is a public endpoint. Anyone can call it directly: ```bash curl -X POST /_rsc/action \ -H 'X-RSC-Action: 0339292364be#placeOrder' \ -H 'X-RSC-Content-Type: text/plain;charset=UTF-8' \ --data-binary '["a rubber duck"]' ``` That is not a hole to plug — an action *is* an RPC endpoint and its id is not a secret. It means the check goes **inside the action**, not in the component that renders the button: ```ts title="src/actions.ts" 'use server' import { currentUser } from './auth'; export async function placeOrder(item: string) { const user = await currentUser(); if (!user) throw new Error('Not signed in'); // … } ``` <Aside type="caution" title="Middleware does not cover actions"> Middleware runs when a route renders. An action renders no route, so none of them run. Each entry point defends itself. </Aside> ### Write the check once One action is fine. Twenty is twenty chances to forget, and the one you forget is the one that matters. Put the check on a client and build every action from it: ```ts title="src/server/client.ts" 'use server' import { createActionClient } from '@rsc-kit/core/action' export const client = createActionClient() .use(async ({ next }) => { const user = await currentUser() if (!user) throw new ServerAuthenticationError() return next({ ctx: { user } }) }) ``` ```ts title="src/server/orders.ts" 'use server' import { client } from './client' export const placeOrder = client.input(schema).handler(async ({ input, ctx }) => orders.create(ctx.user.id, input), ) export const listOrders = client.query(async ({ ctx }) => orders.forUser(ctx.user.id)) ``` `ctx.user` is typed and non-null inside the handler, because the only way to get there was through the middleware that put it in. **An action cannot be added without the check** — not because a rule says so, but because there is no other constructor to reach for. `.handler()` makes an action, `.query()` makes a [read](/guides/queries/), and both run the same chain. Add a second `.use()` for a role check and it applies to everything built from that client: ```ts export const admin = client.use(async ({ ctx, next }) => { if (!ctx.user.isAdmin) throw new ServerAuthorizationError() return next({ ctx }) }) ``` Authorise on **identity, not arguments**. `cancelOrder(id)` that trusts the id is the whole of an IDOR — the caller chooses the id, so the handler has to check the row belongs to `ctx.user`. ## Protect an API route A [route](/guides/api-routes/) runs the same middleware a page in that directory would. Put it under a guarded path and it is guarded: ```text src/app/admin/ middleware.ts ← guards everything below page.tsx ← guarded api/export/route.ts ← guarded too ``` A refused route answers **401** or **403** rather than redirecting, and names the destination in `X-RSC-Redirect` if the middleware wanted one. A `fetch` would follow a redirect and hand back a login page as though it were your data. For a route with no middleware above it, check inside the handler: ```ts title="src/app/api/orders/route.ts" import { currentUser } from '../../../auth'; export async function GET(): Promise<Response> { const user = await currentUser(); if (!user) return new Response('Unauthorized', { status: 401 }); return Response.json(await orders(user.id)); } ``` ## Read the session There is no request object. `headers()` and `cookies()` read the one in flight: ```ts title="src/app/[locale]/middleware.ts" import { redirect } from '@rsc-kit/core/redirect'; import { cookies, headers } from '@rsc-kit/core/request'; export default async function middleware() { const jar = await cookies(); const locale = jar.get('locale')?.value ?? negotiate((await headers()).get('accept-language')); if (!locale) redirect('/en'); } ``` They work anywhere a request is in flight — middleware, server components, actions, API routes — so whatever you already use for locale, feature flags or tenants works inside a plain async function. `request()` gives you the whole `Request` for anything the two do not cover. They are async for a reason worth knowing: at build time there is no request, so a read *suspends*. React freezes the shell above it and only the part that wanted a header renders per visitor. A synchronous read would force the whole page to re-render for everyone. ### Ask once Middleware wants to know who you are; the layout wants their name; the page wants their permissions. Wrap the lookup in `cache()` and that is one query: ```ts title="src/session.ts" import { cache } from '@rsc-kit/core/cache'; export const currentUser = cache(async () => { const id = await sessionId(); return db.user(id); }); ``` The scope is one request. Two requests never see each other's answers, and nothing survives between them. Outside a request it just calls through, so shared code does not need to know where it is running. ### The page knows what the guard decided Middleware runs inside the same request, so a page under it that calls `currentUser()` gets the cached answer, not a second query — and because the guard redirected everyone with `null`, that answer is a user. TypeScript cannot see the guard, though: the type is still `User | null`. Put the guarantee in a helper both sides call: ```ts title="src/session.ts" import { redirect } from '@rsc-kit/core/redirect'; /** The signed-in user. Redirects otherwise, so the return type is `User`. */ export async function requireUser(): Promise<User> { const user = await currentUser(); if (!user) redirect('/', { search: { auth: true } }); return user; } ``` ```ts title="src/app/account/middleware.ts" export default async function middleware() { await requireUser(); } ``` ```tsx title="src/app/account/page.tsx" export default async function Account() { const user = await requireUser(); // User, one lookup for the request return <h1>{user.firstName}</h1>; } ``` `redirect()` returns `never`, which is what narrows the type. And a page rendered from somewhere the guard does not cover redirects too, instead of handing a `null` to a component. ## Set a cookie Middleware runs before the response exists, which makes it the place to put a header or a cookie on it: ```ts title="src/app/account/middleware.ts" import { cookies, responseHeaders } from '@rsc-kit/core/request' // Middleware runs before anything below it renders, which is also before the // host has built a response — so this is the one place left where a header or a // cookie can still be put on it. A component runs after, while the response is // already streaming, and writing from there throws rather than being dropped. // // The page below is frozen at build time and stays frozen: what is written here // is per request, so neither costs the other anything. export default async function middleware() { responseHeaders().set('X-Account-Section', 'yes') const jar = await cookies() if (!jar.has('seen-account')) { jar.set('seen-account', new Date().toISOString(), { httpOnly: true, sameSite: 'lax' }) } } ``` Actions can write too, which is the case that matters — signing someone in is a mutation that has to leave a cookie behind: ```ts title="src/app/login/actions.ts" 'use server'; import { action } from '@rsc-kit/core/action'; import { cookies } from '@rsc-kit/core/request'; export const login = action.input(credentials).handler(async ({ input }) => { const session = await authenticate(input); (await cookies()).set('session', session.token, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 60 * 60 * 24 * 7, }); }); ``` `get`, `set` and `delete` over the request in flight is the whole surface an auth library needs, so you can wire in whichever one you use. <Aside type="caution" title="A component is too late"> By the time a page renders, the status and headers are already on the wire. Writing from a component throws and tells you to move it to middleware — rather than accepting the call and quietly dropping it. </Aside> ## A guarded page can still be frozen Whether the content is the same for everyone, and whether *you* may see it, are different questions. The build answers the first; middleware answers the second, per request. So an internal page whose bytes never vary is frozen at build time and the middleware decides who gets the file: | Route | Navigation | | --- | --- | | Guarded and frozen — check, then serve from disk | 6.7 ms | | Guarded, rendered on demand | 2626.7 ms | | Unguarded, frozen | 0.6 ms | The check runs before the file is read, so a refusal never touches it. <Aside type="caution" title="Two places this does not hold"> **A static export cannot guard anything.** A static host serves files without running code, so exporting a guarded route publishes it to whoever asks. **Do not put a guarded page in a shared cache.** Your origin runs the check; a CDN holding the response serves it to the next caller without asking. </Aside> ## Where each check goes | Question | Where it goes | | --- | --- | | May this person see this section? | `middleware.ts` in its directory | | May this caller run this action? | Inside the action | | May this caller use this endpoint? | Inside the route handler | | Should the page show different things to different people? | The page — it renders per request anyway |