Skip to content

feat(better-auth): add @orpc/better-auth and a generic cookies link plugin - #2237

Draft
Mnigos wants to merge 3 commits into
middleapi:mainfrom
Mnigos:feat/better-auth
Draft

Mnigos wants to merge 3 commits into
middleapi:mainfrom
Mnigos:feat/better-auth

Conversation

@Mnigos

@Mnigos Mnigos commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

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. auth is the instance or ({ context }, input) => auth (sync or async). The session type is inferred from auth.$Infer.Session, no explicit generic. requireSession throws ORPCError('UNAUTHORIZED') only when the session is null; other errors propagate.
    • BetterAuthHandlerPlugin() takes no options. It gives the middlewares access to the request headers, dedupes auth.api.getSession({ headers, returnHeaders: true }) per auth instance within one HTTP request (batch subrequests included), and appends the collected set-cookie values (refreshed session cookie, cookie cache) to the outer response.
    • createBetterAuthContext({ headers }) for server-side call(), 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 sets cookie from a custom store and reports set-cookie values. Works with any HTTP link (RPCLink, OpenAPILink). @orpc/better-auth ships no client export.
  • Docs: rewritten integrations/better-auth (Setup, Protected Procedures, Server-Side Calls, Batching, Clients for browser / Expo / SSR, NestJS, Limitations), new plugins/cookies-management, a pointer from the batch limitations, package tables in api-reference and the READMEs.

Design notes

  • auth lives in the middleware, as suggested. Dedupe is by instance identity (a per-request WeakMap), so a factory returning a fresh instance per call opts out; documented.
  • The plugin uses after = ['~tracing', '~batch'], same placement and reasoning as CORSHandlerPlugin: one routing interceptor run per HTTP request, batch subrequests share the per-request state through the spread context.
  • init is stateless and cheap, because @orpc/nest re-runs plugin init per request. All state lives in the request context.
  • If the context already carries the state (e.g. from createBetterAuthContext), the plugin reuses it. Collected cookies are drained when applied, so a state shared with an outer handler is never applied twice.
  • Request headers are converted to Headers lazily, only when a middleware actually looks up the session.
  • Batch: this deviates from "batch never forwards sub-response headers", but only for the set-cookie values collected from session lookups. Cookies set by procedures themselves still follow the existing batch limitation.
  • CookiesManagementLinkPlugin runs after ~batch (appended transport interceptor, like the compression plugins), so it reads/stores cookies once per actual HTTP request. An explicit cookie header already on the request wins and getCookie is not called. setCookie is awaited before the call resolves, so the next request sees the stored cookies.
  • @better-auth/expo only exposes authClient.getCookie(), no public setter, so the Expo docs wire only getCookie; setCookie is for node or custom stores. Browsers need none of this, only credentials: 'include' (also asked in How to use Session based auth (cookies) with orpc? #1326).

Tests

  • Unit: lazy and memoized lookup, one lookup per request and per instance, UNAUTHORIZED only on null, errors propagate, descriptive error without setup, set-cookie applied only when a lookup happened (also on error responses), appended after procedure-set cookies, existing context state reused.
  • Batch: RPCHandler + BatchHandlerPlugin + BetterAuthHandlerPlugin, three subrequests, getSession called once, cookies on the batch response.
  • Integration with a real betterAuth() instance (memory adapter): RPCLink + BatchLinkPlugin (buffered) + CookiesManagementLinkPlugin against the handler; one getSession call for the batch, refreshed session_token and session_data cookies reach setCookie.
  • Type tests for the inferred session types (instance and factory forms, additionalFields).
  • Both plugins added to tests/plugins/all-plugins.test.ts.

Open questions

  1. Streaming batch mode. In the default streaming mode 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 with buffered as the workaround. Is that acceptable, or would you rather see something else here?
  2. Final name of the link plugin: CookiesManagementLinkPlugin as suggested, or shorter (CookieLinkPlugin)?
  3. Should the link plugin land in its own PR? It is a separate commit, so it is easy to split.
  4. getSession query 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

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
@pkg-pr-new

pkg-pr-new Bot commented Oct 15, 2026

Copy link
Copy Markdown
More templates

@orpc/ai-sdk

npm i https://pkg.pr.new/middleapi/orpc/@orpc/ai-sdk@2237

@orpc/arktype

npm i https://pkg.pr.new/middleapi/orpc/@orpc/arktype@2237

@orpc/better-auth

npm i https://pkg.pr.new/middleapi/orpc/@orpc/better-auth@2237

@orpc/bun

npm i https://pkg.pr.new/middleapi/orpc/@orpc/bun@2237

@orpc/client

npm i https://pkg.pr.new/middleapi/orpc/@orpc/client@2237

@orpc/cloudflare

npm i https://pkg.pr.new/middleapi/orpc/@orpc/cloudflare@2237

@orpc/contract

npm i https://pkg.pr.new/middleapi/orpc/@orpc/contract@2237

@orpc/experimental-effect

npm i https://pkg.pr.new/middleapi/orpc/@orpc/experimental-effect@2237

@orpc/evlog

npm i https://pkg.pr.new/middleapi/orpc/@orpc/evlog@2237

@orpc/hibernation

npm i https://pkg.pr.new/middleapi/orpc/@orpc/hibernation@2237

@orpc/json-schema

npm i https://pkg.pr.new/middleapi/orpc/@orpc/json-schema@2237

@orpc/experimental-lock

npm i https://pkg.pr.new/middleapi/orpc/@orpc/experimental-lock@2237

@orpc/experimental-msw

npm i https://pkg.pr.new/middleapi/orpc/@orpc/experimental-msw@2237

@orpc/nest

npm i https://pkg.pr.new/middleapi/orpc/@orpc/nest@2237

@orpc/next

npm i https://pkg.pr.new/middleapi/orpc/@orpc/next@2237

@orpc/node

npm i https://pkg.pr.new/middleapi/orpc/@orpc/node@2237

@orpc/openapi

npm i https://pkg.pr.new/middleapi/orpc/@orpc/openapi@2237

@orpc/opentelemetry

npm i https://pkg.pr.new/middleapi/orpc/@orpc/opentelemetry@2237

@orpc/pinia-colada

npm i https://pkg.pr.new/middleapi/orpc/@orpc/pinia-colada@2237

@orpc/pino

npm i https://pkg.pr.new/middleapi/orpc/@orpc/pino@2237

@orpc/publisher

npm i https://pkg.pr.new/middleapi/orpc/@orpc/publisher@2237

@orpc/ratelimit

npm i https://pkg.pr.new/middleapi/orpc/@orpc/ratelimit@2237

@orpc/server

npm i https://pkg.pr.new/middleapi/orpc/@orpc/server@2237

@orpc/shared

npm i https://pkg.pr.new/middleapi/orpc/@orpc/shared@2237

@orpc/swr

npm i https://pkg.pr.new/middleapi/orpc/@orpc/swr@2237

@orpc/tanstack-query

npm i https://pkg.pr.new/middleapi/orpc/@orpc/tanstack-query@2237

@orpc/trpc

npm i https://pkg.pr.new/middleapi/orpc/@orpc/trpc@2237

@orpc/valibot

npm i https://pkg.pr.new/middleapi/orpc/@orpc/valibot@2237

@orpc/zod

npm i https://pkg.pr.new/middleapi/orpc/@orpc/zod@2237

commit: e86d108

@codecov

codecov Bot commented Oct 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

⚠️ 5 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

✅ 28 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing Mnigos:feat/better-auth (e86d108) with main (f254e8a)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@Shashank-Salian

Shashank-Salian commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Better Auth integration (@orpc/better-auth)

2 participants