UEA-Prodem
This commit is contained in:
+342
@@ -0,0 +1,342 @@
|
||||
---
|
||||
name: auth
|
||||
description: >
|
||||
Implement JWT/cookie authentication and authorization in tRPC using createContext
|
||||
for user extraction, t.middleware with opts.next({ ctx }) for context narrowing to
|
||||
non-null user, protectedProcedure base pattern, client-side Authorization headers
|
||||
via httpBatchLink headers(), WebSocket connectionParams, and SSE auth via cookies
|
||||
or EventSource polyfill custom headers.
|
||||
type: composition
|
||||
library: trpc
|
||||
library_version: '11.16.0'
|
||||
requires:
|
||||
- server-setup
|
||||
- middlewares
|
||||
- client-setup
|
||||
sources:
|
||||
- www/docs/server/authorization.md
|
||||
- www/docs/client/headers.md
|
||||
- www/docs/client/links/httpSubscriptionLink.md
|
||||
- www/docs/server/websockets.md
|
||||
---
|
||||
|
||||
# tRPC — Auth
|
||||
|
||||
## Setup
|
||||
|
||||
```ts
|
||||
// server/trpc.ts
|
||||
import { initTRPC, TRPCError } from '@trpc/server';
|
||||
import type { CreateHTTPContextOptions } from '@trpc/server/adapters/standalone';
|
||||
|
||||
export async function createContext({ req }: CreateHTTPContextOptions) {
|
||||
async function getUserFromHeader() {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (token) {
|
||||
const user = await verifyJwt(token); // your JWT verification
|
||||
return user; // e.g. { id: string; name: string; role: string }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return { user: await getUserFromHeader() };
|
||||
}
|
||||
|
||||
export type Context = Awaited<ReturnType<typeof createContext>>;
|
||||
|
||||
const t = initTRPC.context<Context>().create();
|
||||
|
||||
export const publicProcedure = t.procedure;
|
||||
|
||||
export const protectedProcedure = t.procedure.use(
|
||||
async function isAuthed(opts) {
|
||||
const { ctx } = opts;
|
||||
if (!ctx.user) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
return opts.next({
|
||||
ctx: {
|
||||
user: ctx.user, // narrows user to non-null
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const router = t.router;
|
||||
```
|
||||
|
||||
```ts
|
||||
// client/trpc.ts
|
||||
import { createTRPCClient, httpBatchLink } from '@trpc/client';
|
||||
import type { AppRouter } from '../server/router';
|
||||
|
||||
let token = '';
|
||||
export function setToken(t: string) {
|
||||
token = t;
|
||||
}
|
||||
|
||||
export const trpc = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: 'http://localhost:3000/trpc',
|
||||
headers() {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Context narrowing with auth middleware
|
||||
|
||||
```ts
|
||||
import { initTRPC, TRPCError } from '@trpc/server';
|
||||
|
||||
type Context = { user: { id: string; role: string } | null };
|
||||
const t = initTRPC.context<Context>().create();
|
||||
|
||||
const isAuthed = t.middleware(async ({ ctx, next }) => {
|
||||
if (!ctx.user) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
return next({ ctx: { user: ctx.user } });
|
||||
});
|
||||
|
||||
const isAdmin = t.middleware(async ({ ctx, next }) => {
|
||||
if (!ctx.user || ctx.user.role !== 'admin') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN' });
|
||||
}
|
||||
return next({ ctx: { user: ctx.user } });
|
||||
});
|
||||
|
||||
export const protectedProcedure = t.procedure.use(isAuthed);
|
||||
export const adminProcedure = t.procedure.use(isAdmin);
|
||||
```
|
||||
|
||||
### SSE subscription auth with EventSource polyfill
|
||||
|
||||
```ts
|
||||
import {
|
||||
createTRPCClient,
|
||||
httpBatchLink,
|
||||
httpSubscriptionLink,
|
||||
splitLink,
|
||||
} from '@trpc/client';
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
import type { AppRouter } from '../server/router';
|
||||
|
||||
const trpc = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
splitLink({
|
||||
condition: (op) => op.type === 'subscription',
|
||||
true: httpSubscriptionLink({
|
||||
url: 'http://localhost:3000/trpc',
|
||||
EventSource: EventSourcePolyfill,
|
||||
eventSourceOptions: async () => {
|
||||
return {
|
||||
headers: {
|
||||
authorization: `Bearer ${getToken()}`,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
false: httpBatchLink({
|
||||
url: 'http://localhost:3000/trpc',
|
||||
headers() {
|
||||
return { Authorization: `Bearer ${getToken()}` };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### WebSocket auth with connectionParams
|
||||
|
||||
```ts
|
||||
// server/context.ts
|
||||
import type { CreateWSSContextFnOptions } from '@trpc/server/adapters/ws';
|
||||
|
||||
export const createContext = async (opts: CreateWSSContextFnOptions) => {
|
||||
const token = opts.info.connectionParams?.token;
|
||||
const user = token ? await verifyJwt(token) : null;
|
||||
return { user };
|
||||
};
|
||||
```
|
||||
|
||||
```ts
|
||||
// client/trpc.ts
|
||||
import { createTRPCClient, createWSClient, wsLink } from '@trpc/client';
|
||||
import type { AppRouter } from '../server/router';
|
||||
|
||||
const wsClient = createWSClient({
|
||||
url: 'ws://localhost:3001',
|
||||
connectionParams: async () => ({
|
||||
token: getToken(),
|
||||
}),
|
||||
});
|
||||
|
||||
const trpc = createTRPCClient<AppRouter>({
|
||||
links: [wsLink({ client: wsClient })],
|
||||
});
|
||||
```
|
||||
|
||||
### SSE auth with cookies (same domain)
|
||||
|
||||
```ts
|
||||
import {
|
||||
createTRPCClient,
|
||||
httpBatchLink,
|
||||
httpSubscriptionLink,
|
||||
splitLink,
|
||||
} from '@trpc/client';
|
||||
import type { AppRouter } from '../server/router';
|
||||
|
||||
const trpc = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
splitLink({
|
||||
condition: (op) => op.type === 'subscription',
|
||||
true: httpSubscriptionLink({
|
||||
url: '/api/trpc',
|
||||
eventSourceOptions() {
|
||||
return { withCredentials: true };
|
||||
},
|
||||
}),
|
||||
false: httpBatchLink({ url: '/api/trpc' }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### HIGH Not narrowing user type in auth middleware
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
const authMiddleware = t.middleware(async ({ ctx, next }) => {
|
||||
if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
return next(); // user still nullable downstream
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
const authMiddleware = t.middleware(async ({ ctx, next }) => {
|
||||
if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
return next({ ctx: { user: ctx.user } }); // narrows to non-null
|
||||
});
|
||||
```
|
||||
|
||||
Without `opts.next({ ctx })`, downstream procedures still see `user` as `{ id: string } | null`, requiring redundant null checks.
|
||||
|
||||
Source: www/docs/server/authorization.md
|
||||
|
||||
### HIGH SSE auth via URL query params exposes tokens
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
httpSubscriptionLink({
|
||||
url: 'http://localhost:3000/trpc',
|
||||
connectionParams: async () => ({
|
||||
token: 'my-secret-jwt',
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
|
||||
httpSubscriptionLink({
|
||||
url: 'http://localhost:3000/trpc',
|
||||
EventSource: EventSourcePolyfill,
|
||||
eventSourceOptions: async () => ({
|
||||
headers: { authorization: 'Bearer my-secret-jwt' },
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
`connectionParams` are serialized as URL query strings for SSE, exposing tokens in server logs and browser history. Use cookies for same-domain or custom headers via an EventSource polyfill instead.
|
||||
|
||||
Source: www/docs/client/links/httpSubscriptionLink.md
|
||||
|
||||
### MEDIUM Async headers causing stuck isFetching
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
httpBatchLink({
|
||||
url: '/api/trpc',
|
||||
async headers() {
|
||||
const token = await refreshToken(); // can race
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
let cachedToken: string | null = null;
|
||||
|
||||
async function ensureToken() {
|
||||
if (!cachedToken) cachedToken = await refreshToken();
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
httpBatchLink({
|
||||
url: '/api/trpc',
|
||||
async headers() {
|
||||
return { Authorization: `Bearer ${await ensureToken()}` };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
When the headers function is async (e.g., refreshing auth tokens), React Query's `isFetching` can get stuck permanently in certain race conditions.
|
||||
|
||||
Source: https://github.com/trpc/trpc/issues/7001
|
||||
|
||||
### HIGH Skipping auth or opening CORS too wide in prototypes
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import cors from 'cors';
|
||||
|
||||
createHTTPServer({
|
||||
middleware: cors(), // origin: '*' by default
|
||||
router: appRouter,
|
||||
createContext() {
|
||||
return {};
|
||||
}, // no auth
|
||||
}).listen(3000);
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import cors from 'cors';
|
||||
|
||||
createHTTPServer({
|
||||
middleware: cors({ origin: 'https://myapp.com' }),
|
||||
router: appRouter,
|
||||
createContext,
|
||||
}).listen(3000);
|
||||
```
|
||||
|
||||
Wildcard CORS and missing auth middleware are acceptable only during local development. Always restrict CORS origins and add auth before deploying.
|
||||
|
||||
Source: maintainer interview
|
||||
|
||||
## See Also
|
||||
|
||||
- **middlewares** -- context narrowing, `.use()`, `.concat()`, base procedure patterns
|
||||
- **subscriptions** -- SSE and WebSocket transport setup for authenticated subscriptions
|
||||
- **client-setup** -- `createTRPCClient`, link chain, `headers` option
|
||||
- **links** -- `splitLink`, `httpSubscriptionLink`, `wsLink` configuration
|
||||
Reference in New Issue
Block a user