@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
Markdown
# 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 |