diff --git a/.agents/skills/kitcn/references/features/react.md b/.agents/skills/kitcn/references/features/react.md index e8da9603..aeb5eccd 100644 --- a/.agents/skills/kitcn/references/features/react.md +++ b/.agents/skills/kitcn/references/features/react.md @@ -513,7 +513,7 @@ export const { createContext, createCaller, handler } = convexBetterAuth({ |--------|-------------| | `createContext` | RSC context with auth | | `createCaller` | Server-side caller factory | -| `handler` | Next.js API route handler (`export const { GET, POST } = handler;`) | +| `handler` | Next.js API route handler (`export const { GET, POST, OPTIONS } = handler;`) | Options: `api`, `convexSiteUrl`, `auth.jwtCache` (default true), `auth.isUnauthorized`. @@ -530,7 +530,7 @@ return {children}; ```ts // src/app/api/auth/[...all]/route.ts import { handler } from '@/lib/convex/server'; -export const { GET, POST } = handler; +export const { GET, POST, OPTIONS } = handler; ``` --- diff --git a/.agents/skills/kitcn/references/setup/next.md b/.agents/skills/kitcn/references/setup/next.md index 533c83b2..fc0fb96d 100644 --- a/.agents/skills/kitcn/references/setup/next.md +++ b/.agents/skills/kitcn/references/setup/next.md @@ -23,7 +23,7 @@ export const { createContext, createCaller, handler } = convexBetterAuth({ ```ts import { handler } from "@/lib/convex/server"; -export const { GET, POST } = handler; +export const { GET, POST, OPTIONS } = handler; ``` ### 8.A.3 RSC helpers diff --git a/.changeset/next-auth-options-preflight.md b/.changeset/next-auth-options-preflight.md new file mode 100644 index 00000000..139e63e6 --- /dev/null +++ b/.changeset/next-auth-options-preflight.md @@ -0,0 +1,5 @@ +--- +"kitcn": patch +--- + +Support `OPTIONS` preflight forwarding in `kitcn/auth/nextjs` route handlers and generated Next auth routes. diff --git a/bun.lock b/bun.lock index 3fb69e3e..4413bff0 100644 --- a/bun.lock +++ b/bun.lock @@ -119,7 +119,7 @@ }, "packages/kitcn": { "name": "kitcn", - "version": "0.15.3", + "version": "0.15.4", "bin": { "kitcn": "./dist/cli.mjs", "intent": "./bin/intent.js", @@ -180,7 +180,7 @@ }, "packages/resend": { "name": "@kitcn/resend", - "version": "0.15.3", + "version": "0.15.4", "dependencies": { "svix": "^1.84.1", }, diff --git a/docs/solutions/integration-issues/nextjs-auth-proxy-must-forward-options-preflight-20260522.md b/docs/solutions/integration-issues/nextjs-auth-proxy-must-forward-options-preflight-20260522.md new file mode 100644 index 00000000..0810c557 --- /dev/null +++ b/docs/solutions/integration-issues/nextjs-auth-proxy-must-forward-options-preflight-20260522.md @@ -0,0 +1,87 @@ +--- +title: Next.js auth proxy must forward OPTIONS preflight +date: 2026-05-22 +category: integration-issues +module: auth-nextjs +problem_type: integration_issue +component: authentication +symptoms: + - cross-origin Better Auth clients need `OPTIONS /api/auth/*` preflight support + - the generated Next.js auth route only exported `GET` and `POST` + - users had to wrap `handler.GET` and `handler.POST` manually to add preflight +root_cause: wrong_api +resolution_type: code_fix +severity: medium +tags: [auth, nextjs, better-auth, cors, preflight, options, convex, proxy] +--- + +# Next.js auth proxy must forward OPTIONS preflight + +## Problem + +The `kitcn/auth/nextjs` proxy only exposed `GET` and `POST`. That forced +cross-origin clients, such as local WebViews, to add a custom Next.js +`OPTIONS` handler even though the Convex auth route already owned CORS policy. + +## Symptoms + +- `convex/functions/http.ts` had `registerRoutes(http, getAuth, { cors })`. +- Better Auth `trustedOrigins` included the cross-origin app origin. +- The Next.js route still needed manual preflight code because + `export const { GET, POST } = handler` left `OPTIONS` unhandled. + +## What Didn't Work + +- Duplicating CORS decisions in the Next.js route is the wrong ownership. The + Convex-side `registerRoutes` helper already combines Better Auth trusted + origins with explicit allowed origins. +- Treating a deployment-wide generic Convex HTTP 500 as this proxy bug is also + wrong. If a bare `/_debug-auth` HTTP action returns the same generic 500 + before handler logs, the failure is below KitCN's Better Auth handler. + +## Solution + +Expose `OPTIONS` from the Next.js proxy and forward it to the Convex site URL. +Do not attach an empty request body to preflight. + +```ts +const requestCanHaveBody = (method: string) => + method !== "GET" && method !== "HEAD" && method !== "OPTIONS"; + +const nextJsHandler = (siteUrl: string) => ({ + GET: (request: Request) => handler(request, siteUrl), + OPTIONS: (request: Request) => handler(request, siteUrl), + POST: (request: Request) => handler(request, siteUrl), +}); +``` + +Generated Next.js auth routes should export all three methods: + +```ts +import { handler } from "@/lib/convex/server"; + +export const { GET, POST, OPTIONS } = handler; +``` + +## Why This Works + +The Next.js route is only a proxy. Forwarding `OPTIONS` lets Convex's HTTP +router answer preflight with the same `registerRoutes` CORS configuration that +answers direct Convex auth requests. + +Keeping preflight bodyless avoids subtle fetch/runtime behavior around methods +that do not need a body. + +## Prevention + +1. When adding a server-side auth proxy, expose every method needed by browser + CORS, not just the methods Better Auth uses for real work. +2. Keep CORS allow-list decisions at one layer. For KitCN auth, that layer is + Convex `registerRoutes`, not the Next.js proxy. +3. Test the public `convexBetterAuth(...).handler` surface for `OPTIONS` + forwarding and generated route templates for `GET, POST, OPTIONS`. + +## Related Issues + +- [Next.js auth proxy must forward POST bodies explicitly](/Users/zbeyens/git/better-convex/docs/solutions/integration-issues/nextjs-auth-proxy-must-forward-post-bodies-explicitly-20260410.md) +- [Convex auth JWKS routes should not trigger Better Auth IP warnings](/Users/zbeyens/git/better-convex/docs/solutions/integration-issues/convex-auth-jwks-routes-should-not-trigger-better-auth-ip-warnings-20260325.md) diff --git a/fixtures/next-auth/app/api/auth/[...all]/route.ts b/fixtures/next-auth/app/api/auth/[...all]/route.ts index bf07421f..ad7d45d3 100644 --- a/fixtures/next-auth/app/api/auth/[...all]/route.ts +++ b/fixtures/next-auth/app/api/auth/[...all]/route.ts @@ -1,3 +1,3 @@ import { handler } from '@/lib/convex/server'; -export const { GET, POST } = handler; +export const { GET, POST, OPTIONS } = handler; diff --git a/packages/kitcn/skills/kitcn/references/features/react.md b/packages/kitcn/skills/kitcn/references/features/react.md index e8da9603..aeb5eccd 100644 --- a/packages/kitcn/skills/kitcn/references/features/react.md +++ b/packages/kitcn/skills/kitcn/references/features/react.md @@ -513,7 +513,7 @@ export const { createContext, createCaller, handler } = convexBetterAuth({ |--------|-------------| | `createContext` | RSC context with auth | | `createCaller` | Server-side caller factory | -| `handler` | Next.js API route handler (`export const { GET, POST } = handler;`) | +| `handler` | Next.js API route handler (`export const { GET, POST, OPTIONS } = handler;`) | Options: `api`, `convexSiteUrl`, `auth.jwtCache` (default true), `auth.isUnauthorized`. @@ -530,7 +530,7 @@ return {children}; ```ts // src/app/api/auth/[...all]/route.ts import { handler } from '@/lib/convex/server'; -export const { GET, POST } = handler; +export const { GET, POST, OPTIONS } = handler; ``` --- diff --git a/packages/kitcn/skills/kitcn/references/setup/next.md b/packages/kitcn/skills/kitcn/references/setup/next.md index 533c83b2..fc0fb96d 100644 --- a/packages/kitcn/skills/kitcn/references/setup/next.md +++ b/packages/kitcn/skills/kitcn/references/setup/next.md @@ -23,7 +23,7 @@ export const { createContext, createCaller, handler } = convexBetterAuth({ ```ts import { handler } from "@/lib/convex/server"; -export const { GET, POST } = handler; +export const { GET, POST, OPTIONS } = handler; ``` ### 8.A.3 RSC helpers diff --git a/packages/kitcn/src/auth-nextjs/index.test.ts b/packages/kitcn/src/auth-nextjs/index.test.ts index 6302ea16..a4114cf5 100644 --- a/packages/kitcn/src/auth-nextjs/index.test.ts +++ b/packages/kitcn/src/auth-nextjs/index.test.ts @@ -1,7 +1,7 @@ import { convexBetterAuth } from './index'; describe('convexBetterAuth', () => { - test('creates GET/POST handlers that rewrite request URL to convex site', async () => { + test('creates GET/POST/OPTIONS handlers that rewrite request URL to convex site', async () => { const originalFetch = globalThis.fetch; const calls: Array<{ input: RequestInfo | URL; @@ -34,8 +34,17 @@ describe('convexBetterAuth', () => { method: 'POST', }) ); + const preflightRequest = { + headers: { + 'access-control-request-method': 'POST', + origin: 'http://localhost:1420', + }, + method: 'OPTIONS', + url: 'https://example.com/api/auth/session', + } as unknown as Request; + await result.handler.OPTIONS(preflightRequest); - expect(calls).toHaveLength(2); + expect(calls).toHaveLength(3); expect(calls[0]?.input).toBe('https://my-app.convex.site/path?a=1'); expect(calls[0]?.init?.method).toBe('GET'); @@ -66,6 +75,18 @@ describe('convexBetterAuth', () => { await expect( new Response(calls[1]?.init?.body as BodyInit).text() ).resolves.toBe(JSON.stringify({ email: 'user@example.com' })); + + expect(calls[2]?.input).toBe( + 'https://my-app.convex.site/api/auth/session' + ); + expect(calls[2]?.init?.method).toBe('OPTIONS'); + expect(calls[2]?.init?.redirect).toBe('manual'); + const optionsHeaders = new Headers(calls[2]?.init?.headers); + expect(optionsHeaders.get('host')).toBe('my-app.convex.site'); + expect(optionsHeaders.get('x-forwarded-host')).toBe('example.com'); + expect(optionsHeaders.get('x-forwarded-proto')).toBe('https'); + expect(optionsHeaders.get('origin')).toBe('http://localhost:1420'); + expect(calls[2]?.init?.body).toBeUndefined(); } finally { globalThis.fetch = originalFetch; } diff --git a/packages/kitcn/src/auth-nextjs/index.ts b/packages/kitcn/src/auth-nextjs/index.ts index a520a607..ae02c851 100644 --- a/packages/kitcn/src/auth-nextjs/index.ts +++ b/packages/kitcn/src/auth-nextjs/index.ts @@ -10,6 +10,9 @@ import { createCallerFactory } from '../server/caller-factory'; const TRAILING_COLON_RE = /:$/; +const requestCanHaveBody = (method: string) => + method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS'; + const handler = async (request: Request, siteUrl: string) => { const requestUrl = new URL(request.url); const nextUrl = `${siteUrl}${requestUrl.pathname}${requestUrl.search}`; @@ -26,10 +29,9 @@ const handler = async (request: Request, siteUrl: string) => { 'x-better-auth-forwarded-proto', requestUrl.protocol.replace(TRAILING_COLON_RE, '') ); - const body = - request.method !== 'GET' && request.method !== 'HEAD' - ? await request.arrayBuffer() - : undefined; + const body = requestCanHaveBody(request.method) + ? await request.arrayBuffer() + : undefined; return fetch(nextUrl, { body, @@ -41,6 +43,7 @@ const handler = async (request: Request, siteUrl: string) => { const nextJsHandler = (siteUrl: string) => ({ GET: (request: Request) => handler(request, siteUrl), + OPTIONS: (request: Request) => handler(request, siteUrl), POST: (request: Request) => handler(request, siteUrl), }); diff --git a/packages/kitcn/src/cli/cli.commands.ts b/packages/kitcn/src/cli/cli.commands.ts index 7aff25b2..9bfe1007 100644 --- a/packages/kitcn/src/cli/cli.commands.ts +++ b/packages/kitcn/src/cli/cli.commands.ts @@ -1944,7 +1944,9 @@ describe('cli/cli', () => { expect(routeSource).toContain( "import { handler } from '@/lib/convex/server';" ); - expect(routeSource).toContain('export const { GET, POST } = handler;'); + expect(routeSource).toContain( + 'export const { GET, POST, OPTIONS } = handler;' + ); const authPageSource = fs.readFileSync( path.join(dir, 'app', 'auth', 'page.tsx'), diff --git a/packages/kitcn/src/cli/registry/items/auth/auth-next-route.template.ts b/packages/kitcn/src/cli/registry/items/auth/auth-next-route.template.ts index 21a34867..102f3820 100644 --- a/packages/kitcn/src/cli/registry/items/auth/auth-next-route.template.ts +++ b/packages/kitcn/src/cli/registry/items/auth/auth-next-route.template.ts @@ -1,4 +1,4 @@ export const AUTH_NEXT_ROUTE_TEMPLATE = `import { handler } from '@/lib/convex/server'; -export const { GET, POST } = handler; +export const { GET, POST, OPTIONS } = handler; `; diff --git a/www/content/docs/migrations/auth.mdx b/www/content/docs/migrations/auth.mdx index 96c8259d..cb65c6b3 100644 --- a/www/content/docs/migrations/auth.mdx +++ b/www/content/docs/migrations/auth.mdx @@ -739,7 +739,7 @@ API route: ```ts showLineNumbers title="app/api/auth/[...all]/route.ts" import { handler } from "@/lib/auth-server"; -export const { GET, POST } = handler; +export const { GET, POST, OPTIONS } = handler; ``` Root layout: diff --git a/www/content/docs/nextjs/index.mdx b/www/content/docs/nextjs/index.mdx index 2b5ceb52..5aa0f5b9 100644 --- a/www/content/docs/nextjs/index.mdx +++ b/www/content/docs/nextjs/index.mdx @@ -146,7 +146,7 @@ Create the Next.js API route for Better Auth: ```ts title="src/app/api/auth/[...all]/route.ts" showLineNumbers {3} import { handler } from '@/lib/convex/server'; -export const { GET, POST } = handler; +export const { GET, POST, OPTIONS } = handler; ``` ## RSC Setup