Repository navigation
Conversation
Sends a cookie header from a custom store and reports set-cookie values of each response, for clients without a cookie jar such as Expo, React Native, Node.js, or SSR. Runs as a transport interceptor after batching, so it works with any HTTP link and reads/stores cookies once per batch.
- requireSession / optionalSession middlewares take the auth instance (or a function returning it) and infer the session type from auth.$Infer - BetterAuthHandlerPlugin gives the middlewares access to request headers, shares session lookups per auth instance across batch subrequests, and applies set-cookie headers returned by auth.api.getSession (refreshed session, cookie cache) to the response - createBetterAuthContext for server-side calls without the plugin - rewrite the Better Auth integration docs page Closes middleapi#1892
More templates
@orpc/ai-sdk
@orpc/arktype
@orpc/better-auth
@orpc/bun
@orpc/client
@orpc/cloudflare
@orpc/contract
@orpc/experimental-effect
@orpc/evlog
@orpc/hibernation
@orpc/json-schema
@orpc/experimental-lock
@orpc/experimental-msw
@orpc/nest
@orpc/next
@orpc/node
@orpc/openapi
@orpc/opentelemetry
@orpc/pinia-colada
@orpc/pino
@orpc/publisher
@orpc/ratelimit
@orpc/server
@orpc/shared
@orpc/swr
@orpc/tanstack-query
@orpc/trpc
@orpc/valibot
@orpc/zod
commit: |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Merging this PR will not alter performance
|
|
Better Auth also has Access control built in when using Admin and Organization plugin. Will this PR contain a middleware for the same ? In my codebase I have something like this: export interface PermissionMeta {
/** Resource permissions the caller must hold, e.g. `{ client: ["create"] }`. */
permissions?: {
[K in keyof AccessControlStatements]?: AccessControlStatements[K] extends readonly (infer T)[]
? T[]
: string[];
};
/** When true the caller must be authenticated (no specific permission required). */
authenticated?: boolean;
}
export function requirePermission(options: PermissionMeta): AnyMetaPlugin {
return {
name: "~permission",
init(meta: Meta): Meta {
return { ...meta, "~permission": options };
},
};
}
export function getPermissionMeta(procedure: AnyProcedure): PermissionMeta | undefined {
const raw: unknown = procedure["~orpc"].meta["~permission"];
if (typeof raw !== "object" || raw === null) return undefined;
return raw as PermissionMeta;
}
// Middleware
export const permissionMiddleware = base.middleware(async ({ context, procedure, next }) => {
const permissionMeta = getPermissionMeta(procedure);
if (!permissionMeta) {
return next({ context });
}
if (permissionMeta.authenticated) {
const session = await resolveSession(context);
if (!session) throw new UnauthorizedError();
}
if (permissionMeta.permissions) {
const permissionsRecord: Record<string, string[]> = {};
for (const [key, value] of Object.entries(permissionMeta.permissions)) {
if (value !== undefined) {
permissionsRecord[key] = value;
}
}
const result = await context.auth.api.hasPermission({
headers: context.reqHeaders ?? new Headers(),
body: { permissions: permissionsRecord },
});
if (!result?.success) {
throw new ForbiddenError();
}
}
return next({ context });
});
// Usage
const clientOc = oc.meta(openapi({ prefix: "/client", tags: ["client"] }));
export const createClient = clientOc
.meta(
openapi({
method: "POST",
path: "/",
operationId: "createClient",
summary: "Create a client",
}),
requirePermission({ permissions: { client: ["create"] } }),
)
.input(createClientSchema)
.output(coercedNumberIdSchema)
.errors(clientErrors);This code snippet is strongly typed and coupled to my codebase, maybe you can create a generic version of the same |
Implements the shape agreed in #1892 after the RFC discussion (#1892 (comment)).
Summary
@orpc/better-auth(new package, same layout as@orpc/pino)requireSession({ auth })/optionalSession({ auth })middlewares.authis the instance or({ context }, input) => auth(sync or async). The session type is inferred fromauth.$Infer.Session, no explicit generic.requireSessionthrowsORPCError('UNAUTHORIZED')only when the session isnull; other errors propagate.BetterAuthHandlerPlugin()takes no options. It gives the middlewares access to the request headers, dedupesauth.api.getSession({ headers, returnHeaders: true })perauthinstance within one HTTP request (batch subrequests included), and appends the collectedset-cookievalues (refreshed session cookie, cookie cache) to the outer response.createBetterAuthContext({ headers })for server-sidecall(), tests and custom entrypoints. Without the plugin or this context the middlewares throw a descriptive error, so a missing setup is not mistaken for a signed-out request.@orpc/client:CookiesManagementLinkPlugin({ getCookie, setCookie? }), a general-purpose transport interceptor that setscookiefrom a custom store and reportsset-cookievalues. Works with any HTTP link (RPCLink, OpenAPILink).@orpc/better-authships no client export.integrations/better-auth(Setup, Protected Procedures, Server-Side Calls, Batching, Clients for browser / Expo / SSR, NestJS, Limitations), newplugins/cookies-management, a pointer from the batch limitations, package tables inapi-referenceand the READMEs.Design notes
authlives in the middleware, as suggested. Dedupe is by instance identity (a per-requestWeakMap), so a factory returning a fresh instance per call opts out; documented.after = ['~tracing', '~batch'], same placement and reasoning asCORSHandlerPlugin: one routing interceptor run per HTTP request, batch subrequests share the per-request state through the spread context.initis stateless and cheap, because@orpc/nestre-runs plugininitper request. All state lives in the request context.createBetterAuthContext), the plugin reuses it. Collected cookies are drained when applied, so a state shared with an outer handler is never applied twice.Headerslazily, only when a middleware actually looks up the session.set-cookievalues collected from session lookups. Cookies set by procedures themselves still follow the existing batch limitation.CookiesManagementLinkPluginruns after~batch(appended transport interceptor, like the compression plugins), so it reads/stores cookies once per actual HTTP request. An explicitcookieheader already on the request wins andgetCookieis not called.setCookieis awaited before the call resolves, so the next request sees the stored cookies.@better-auth/expoonly exposesauthClient.getCookie(), no public setter, so the Expo docs wire onlygetCookie;setCookieis for node or custom stores. Browsers need none of this, onlycredentials: 'include'(also asked in How to use Session based auth (cookies) with orpc? #1326).Tests
UNAUTHORIZEDonly onnull, errors propagate, descriptive error without setup,set-cookieapplied only when a lookup happened (also on error responses), appended after procedure-set cookies, existing context state reused.getSessioncalled once, cookies on the batch response.betterAuth()instance (memory adapter): RPCLink + BatchLinkPlugin (buffered) + CookiesManagementLinkPlugin against the handler; onegetSessioncall for the batch, refreshedsession_tokenandsession_datacookies reachsetCookie.additionalFields).tests/plugins/all-plugins.test.ts.Open questions
streamingmode the batch response headers are sent before the subrequests run, so cookies from session lookups cannot be forwarded there; the session is still resolved and checked. Documented as a limitation withbufferedas the workaround. Is that acceptable, or would you rather see something else here?CookiesManagementLinkPluginas suggested, or shorter (CookieLinkPlugin)?getSessionquery options (disableCookieCache,disableRefresh) are not exposed in the middlewares yet; they would need to be part of the dedupe key. Worth adding now or later?Closes #1892