From 011ee9f681403244d45af557e149799b60cc4250 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 14 May 2026 11:14:13 -0500 Subject: [PATCH 1/4] chore: fix bug-report --- .github/ISSUE_TEMPLATE/bug_report.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 0c888f6..32c1253 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -27,8 +27,8 @@ If applicable, add screenshots to help explain your problem. - OS: [e.g. iOS] - Browser [e.g. chrome, safari] -- authkit-nextjs version [e.g. 0.12.0] -- Next.js version [e.g. 14.2.5] +- authkit-tanstack-start version [e.g. 0.12.0] +- Tanstack Start version [e.g. 14.2.5] **Additional context** Add any other context about the problem here. From 38b2888adb76a5d25881feb3d822b3c58f0d7bac Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 14 May 2026 12:30:32 -0500 Subject: [PATCH 2/4] fix: eliminate middleware bundle leak via lazy dynamic import (#82) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `authkitMiddleware()` statically imported `authkit-loader.ts`, which pulled `@workos/authkit-session` → `@workos-inc/node` → `eventemitter3` into the client module graph during Vite dev. Unlike `createServerFn` handlers (which the TanStack compiler rewrites to `createClientRpc` stubs), `createMiddleware().server()` callbacks get no such rewrite. Apply the same lazy-body pattern from #75: middleware.ts is now a thin shell that `await import()`s middleware-body.ts inside the `.server()` callback. The dynamic import is dead-code-eliminated from the client graph. Also: - Add middleware.ts to oxlint no-restricted-imports override - Add eventemitter3 to bundle-leak fingerprints - Split tests: shell delegation in middleware.spec.ts, logic in middleware-body.spec.ts Resolves #82. --- .oxlintrc.json | 5 +- scripts/check-bundle-leak.sh | 1 + src/server/middleware-body.spec.ts | 291 +++++++++++++++++++++++++++ src/server/middleware-body.ts | 61 ++++++ src/server/middleware.spec.ts | 313 ++--------------------------- src/server/middleware.ts | 63 +----- 6 files changed, 374 insertions(+), 360 deletions(-) create mode 100644 src/server/middleware-body.spec.ts create mode 100644 src/server/middleware-body.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 6bb25f6..071f3aa 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -6,7 +6,7 @@ "ignorePatterns": ["dist/", "example/src/routeTree.gen.ts"], "overrides": [ { - "files": ["src/server/actions.ts", "src/server/server-functions.ts"], + "files": ["src/server/actions.ts", "src/server/server-functions.ts", "src/server/middleware.ts"], "rules": { "no-restricted-imports": [ "error", @@ -19,7 +19,8 @@ "./context*", "./headers-bag*", "./action-bodies*", - "./server-fn-bodies*" + "./server-fn-bodies*", + "./middleware-body*" ], "allowTypeImports": true, "message": "Static value imports of server-only modules are forbidden in this file. Move logic into action-bodies.ts or server-fn-bodies.ts and use a dynamic import inside the handler. See CLAUDE.md." diff --git a/scripts/check-bundle-leak.sh b/scripts/check-bundle-leak.sh index 0d31dce..16821f4 100755 --- a/scripts/check-bundle-leak.sh +++ b/scripts/check-bundle-leak.sh @@ -18,6 +18,7 @@ FINGERPRINTS=( "@workos-inc/node" "iron-session" "iron-webcrypto" + "eventemitter3" # Code fingerprints (more robust against minification) "FeatureFlagsRuntimeClient" "The listener must be a function" diff --git a/src/server/middleware-body.spec.ts b/src/server/middleware-body.spec.ts new file mode 100644 index 0000000..26e08bc --- /dev/null +++ b/src/server/middleware-body.spec.ts @@ -0,0 +1,291 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockAuthkit = { + withAuth: vi.fn(), + saveSession: vi.fn(), +}; + +const mockGetConfig = vi.fn(); + +vi.mock('./authkit-loader', () => ({ + getAuthkit: vi.fn(() => Promise.resolve(mockAuthkit)), + validateConfig: vi.fn(() => Promise.resolve()), + getConfig: () => mockGetConfig(), +})); + +import { middlewareBody } from './middleware-body'; + +describe('middlewareBody', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('header merging', () => { + it('uses Headers API for pendingHeaders', async () => { + mockAuthkit.withAuth.mockResolvedValue({ + auth: { user: null }, + refreshedSessionData: null, + }); + + const mockRequest = new Request('http://test.local'); + const mockResponse = new Response('OK', { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }); + + const args = { + request: mockRequest, + next: vi.fn().mockResolvedValue({ response: mockResponse }), + }; + + const result = await middlewareBody(args); + + expect(result.response).toBe(mockResponse); + }); + + it('appends Set-Cookie headers correctly', async () => { + mockAuthkit.withAuth.mockResolvedValue({ + auth: { user: null }, + refreshedSessionData: null, + }); + + const mockRequest = new Request('http://test.local'); + const mockResponse = new Response('OK', { status: 200 }); + + const args = { + request: mockRequest, + next: vi.fn(async ({ context }: any) => { + context.__setPendingHeader('Set-Cookie', 'session=abc123; Path=/'); + return { response: mockResponse }; + }), + }; + + const result = await middlewareBody(args); + + expect(result.response.headers.get('Set-Cookie')).toBe('session=abc123; Path=/'); + }); + + it('supports multiple Set-Cookie headers via append', async () => { + mockAuthkit.withAuth.mockResolvedValue({ + auth: { user: null }, + refreshedSessionData: null, + }); + + const mockRequest = new Request('http://test.local'); + const mockResponse = new Response('OK', { status: 200 }); + + const args = { + request: mockRequest, + next: vi.fn(async ({ context }: any) => { + context.__setPendingHeader('Set-Cookie', 'cookie1=value1; Path=/'); + context.__setPendingHeader('Set-Cookie', 'cookie2=value2; Path=/'); + return { response: mockResponse }; + }), + }; + + const result = await middlewareBody(args); + + const cookies = result.response.headers.get('Set-Cookie'); + expect(cookies).toContain('cookie1=value1'); + expect(cookies).toContain('cookie2=value2'); + }); + + it('uses set() for non-cookie headers', async () => { + mockAuthkit.withAuth.mockResolvedValue({ + auth: { user: null }, + refreshedSessionData: null, + }); + + const mockRequest = new Request('http://test.local'); + const mockResponse = new Response('OK', { status: 200 }); + + const args = { + request: mockRequest, + next: vi.fn(async ({ context }: any) => { + context.__setPendingHeader('X-Custom', 'value1'); + context.__setPendingHeader('X-Custom', 'value2'); + return { response: mockResponse }; + }), + }; + + const result = await middlewareBody(args); + + expect(result.response.headers.get('X-Custom')).toBe('value2'); + }); + + it('handles refreshed session data via storage context', async () => { + const refreshedData = 'encrypted_session_data'; + mockAuthkit.withAuth.mockResolvedValue({ + auth: { user: { id: 'user_123' } }, + refreshedSessionData: refreshedData, + }); + mockAuthkit.saveSession.mockResolvedValue({ + headers: { 'Set-Cookie': 'wos-session=new_value; Path=/' }, + }); + + const mockRequest = new Request('http://test.local'); + const mockResponse = new Response('OK', { status: 200 }); + + const args = { + request: mockRequest, + next: vi.fn(async () => ({ response: mockResponse })), + }; + + await middlewareBody(args); + + expect(mockAuthkit.saveSession).toHaveBeenCalledWith(undefined, refreshedData); + }); + + it('provides correct context shape to downstream handlers', async () => { + const mockAuth = { user: { id: 'user_123' }, sessionId: 'session_123' }; + mockAuthkit.withAuth.mockResolvedValue({ + auth: mockAuth, + refreshedSessionData: null, + }); + + const mockRequest = new Request('http://test.local'); + const mockResponse = new Response('OK', { status: 200 }); + + let capturedContext: any = null; + const args = { + request: mockRequest, + next: vi.fn(async ({ context }: any) => { + capturedContext = context; + return { response: mockResponse }; + }), + }; + + await middlewareBody(args); + + expect(capturedContext.request).toBe(mockRequest); + expect(typeof capturedContext.auth).toBe('function'); + expect(capturedContext.auth()).toBe(mockAuth); + expect(typeof capturedContext.__setPendingHeader).toBe('function'); + }); + + it('preserves existing response headers', async () => { + mockAuthkit.withAuth.mockResolvedValue({ + auth: { user: null }, + refreshedSessionData: null, + }); + + const mockRequest = new Request('http://test.local'); + const mockResponse = new Response('OK', { + status: 200, + headers: { 'X-Existing': 'preserved' }, + }); + + const args = { + request: mockRequest, + next: vi.fn(async ({ context }: any) => { + context.__setPendingHeader('X-New', 'added'); + return { response: mockResponse }; + }), + }; + + const result = await middlewareBody(args); + + expect(result.response.headers.get('X-Existing')).toBe('preserved'); + expect(result.response.headers.get('X-New')).toBe('added'); + }); + }); + + describe('redirectUri option', () => { + it('passes redirectUri to context when provided', async () => { + mockAuthkit.withAuth.mockResolvedValue({ + auth: { user: null }, + refreshedSessionData: null, + }); + + const mockRequest = new Request('http://test.local'); + const mockResponse = new Response('OK', { status: 200 }); + + let capturedContext: any = null; + const args = { + request: mockRequest, + next: vi.fn(async ({ context }: any) => { + capturedContext = context; + return { response: mockResponse }; + }), + }; + + await middlewareBody(args, { redirectUri: 'https://custom.example.com/callback' }); + + expect(capturedContext.redirectUri).toBe('https://custom.example.com/callback'); + }); + + it('passes undefined redirectUri when not provided', async () => { + mockAuthkit.withAuth.mockResolvedValue({ + auth: { user: null }, + refreshedSessionData: null, + }); + mockGetConfig.mockResolvedValue(undefined); + + const mockRequest = new Request('http://test.local'); + const mockResponse = new Response('OK', { status: 200 }); + + let capturedContext: any = null; + const args = { + request: mockRequest, + next: vi.fn(async ({ context }: any) => { + capturedContext = context; + return { response: mockResponse }; + }), + }; + + await middlewareBody(args); + + expect(capturedContext.redirectUri).toBeUndefined(); + }); + + it('uses WORKOS_REDIRECT_URI from config when option not provided', async () => { + const envRedirectUri = 'https://env.example.com/callback'; + mockAuthkit.withAuth.mockResolvedValue({ + auth: { user: null }, + refreshedSessionData: null, + }); + mockGetConfig.mockResolvedValue(envRedirectUri); + + const mockRequest = new Request('http://test.local'); + const mockResponse = new Response('OK', { status: 200 }); + + let capturedContext: any = null; + const args = { + request: mockRequest, + next: vi.fn(async ({ context }: any) => { + capturedContext = context; + return { response: mockResponse }; + }), + }; + + await middlewareBody(args); + + expect(capturedContext.redirectUri).toBe(envRedirectUri); + }); + + it('prioritizes explicit option over config', async () => { + const explicitRedirectUri = 'https://explicit.example.com/callback'; + mockAuthkit.withAuth.mockResolvedValue({ + auth: { user: null }, + refreshedSessionData: null, + }); + mockGetConfig.mockResolvedValue('https://env.example.com/callback'); + + const mockRequest = new Request('http://test.local'); + const mockResponse = new Response('OK', { status: 200 }); + + let capturedContext: any = null; + const args = { + request: mockRequest, + next: vi.fn(async ({ context }: any) => { + capturedContext = context; + return { response: mockResponse }; + }), + }; + + await middlewareBody(args, { redirectUri: explicitRedirectUri }); + + expect(capturedContext.redirectUri).toBe(explicitRedirectUri); + }); + }); +}); diff --git a/src/server/middleware-body.ts b/src/server/middleware-body.ts new file mode 100644 index 0000000..98bd53a --- /dev/null +++ b/src/server/middleware-body.ts @@ -0,0 +1,61 @@ +import { getAuthkit, validateConfig, getConfig } from './authkit-loader.js'; +import type { AuthKitMiddlewareOptions } from './middleware.js'; + +let configValidated = false; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function middlewareBody(args: any, options?: AuthKitMiddlewareOptions) { + const authkit = await getAuthkit(); + + if (!configValidated) { + await validateConfig(); + configValidated = true; + } + + const { auth, refreshedSessionData } = await authkit.withAuth(args.request); + const pendingHeaders = new Headers(); + + const result = await args.next({ + context: { + auth: () => auth, + request: args.request, + redirectUri: options?.redirectUri ?? (await getConfig('redirectUri')), + __setPendingHeader: (key: string, value: string) => { + if (key.toLowerCase() === 'set-cookie') { + pendingHeaders.append(key, value); + } else { + pendingHeaders.set(key, value); + } + }, + }, + }); + + if (refreshedSessionData) { + const { response: sessionResponse } = await authkit.saveSession(undefined, refreshedSessionData); + const setCookieHeader = sessionResponse?.headers.get('Set-Cookie'); + if (setCookieHeader) { + pendingHeaders.append('Set-Cookie', setCookieHeader); + } + } + + const headerEntries = [...pendingHeaders]; + if (headerEntries.length === 0) { + return result; + } + + const newResponse = new Response(result.response.body, { + status: result.response.status, + statusText: result.response.statusText, + headers: result.response.headers, + }); + + for (const [key, value] of headerEntries) { + if (key.toLowerCase() === 'set-cookie') { + newResponse.headers.append(key, value); + } else { + newResponse.headers.set(key, value); + } + } + + return { ...result, response: newResponse }; +} diff --git a/src/server/middleware.spec.ts b/src/server/middleware.spec.ts index 11182a5..d306d4a 100644 --- a/src/server/middleware.spec.ts +++ b/src/server/middleware.spec.ts @@ -1,17 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -// Mock dependencies before imports -const mockAuthkit = { - withAuth: vi.fn(), - saveSession: vi.fn(), -}; +const mockMiddlewareBody = vi.fn(); -const mockGetConfig = vi.fn(); - -vi.mock('./authkit-loader', () => ({ - getAuthkit: vi.fn(() => Promise.resolve(mockAuthkit)), - validateConfig: vi.fn(() => Promise.resolve()), - getConfig: () => mockGetConfig(), +vi.mock('./middleware-body', () => ({ + middlewareBody: (...args: unknown[]) => mockMiddlewareBody(...args), })); let middlewareServerCallback: any = null; @@ -33,299 +25,26 @@ describe('authkitMiddleware', () => { middlewareServerCallback = null; }); - describe('header merging', () => { - it('uses Headers API for pendingHeaders', async () => { - mockAuthkit.withAuth.mockResolvedValue({ - auth: { user: null }, - refreshedSessionData: null, - }); - - authkitMiddleware(); - - const mockRequest = new Request('http://test.local'); - const mockResponse = new Response('OK', { - status: 200, - headers: { 'Content-Type': 'text/plain' }, - }); - - const args = { - request: mockRequest, - next: vi.fn().mockResolvedValue({ response: mockResponse }), - }; - - const result = await middlewareServerCallback(args); - - // No headers to add, should return original result - expect(result.response).toBe(mockResponse); - }); - - it('appends Set-Cookie headers correctly', async () => { - mockAuthkit.withAuth.mockResolvedValue({ - auth: { user: null }, - refreshedSessionData: null, - }); - - authkitMiddleware(); - - const mockRequest = new Request('http://test.local'); - const mockResponse = new Response('OK', { status: 200 }); - - const args = { - request: mockRequest, - next: vi.fn(async ({ context }: any) => { - // Simulate action setting cookie via context - context.__setPendingHeader('Set-Cookie', 'session=abc123; Path=/'); - return { response: mockResponse }; - }), - }; - - const result = await middlewareServerCallback(args); - - expect(result.response.headers.get('Set-Cookie')).toBe('session=abc123; Path=/'); - }); - - it('supports multiple Set-Cookie headers via append', async () => { - mockAuthkit.withAuth.mockResolvedValue({ - auth: { user: null }, - refreshedSessionData: null, - }); - - authkitMiddleware(); - - const mockRequest = new Request('http://test.local'); - const mockResponse = new Response('OK', { status: 200 }); - - const args = { - request: mockRequest, - next: vi.fn(async ({ context }: any) => { - // Simulate multiple cookies being set - context.__setPendingHeader('Set-Cookie', 'cookie1=value1; Path=/'); - context.__setPendingHeader('Set-Cookie', 'cookie2=value2; Path=/'); - return { response: mockResponse }; - }), - }; - - const result = await middlewareServerCallback(args); - - // Headers API getAll or iterate to check multiple values - const cookies = result.response.headers.get('Set-Cookie'); - // Note: Headers.get() concatenates multiple values with ", " - expect(cookies).toContain('cookie1=value1'); - expect(cookies).toContain('cookie2=value2'); - }); - - it('uses set() for non-cookie headers', async () => { - mockAuthkit.withAuth.mockResolvedValue({ - auth: { user: null }, - refreshedSessionData: null, - }); - - authkitMiddleware(); - - const mockRequest = new Request('http://test.local'); - const mockResponse = new Response('OK', { status: 200 }); - - const args = { - request: mockRequest, - next: vi.fn(async ({ context }: any) => { - context.__setPendingHeader('X-Custom', 'value1'); - context.__setPendingHeader('X-Custom', 'value2'); // Should overwrite - return { response: mockResponse }; - }), - }; - - const result = await middlewareServerCallback(args); - - expect(result.response.headers.get('X-Custom')).toBe('value2'); - }); - - it('handles refreshed session data via storage context', async () => { - const refreshedData = 'encrypted_session_data'; - mockAuthkit.withAuth.mockResolvedValue({ - auth: { user: { id: 'user_123' } }, - refreshedSessionData: refreshedData, - }); - mockAuthkit.saveSession.mockResolvedValue({ - headers: { 'Set-Cookie': 'wos-session=new_value; Path=/' }, - }); - - authkitMiddleware(); - - const mockRequest = new Request('http://test.local'); - const mockResponse = new Response('OK', { status: 200 }); - - const args = { - request: mockRequest, - next: vi.fn(async () => ({ response: mockResponse })), - }; - - await middlewareServerCallback(args); - - expect(mockAuthkit.saveSession).toHaveBeenCalledWith(undefined, refreshedData); - }); + it('delegates to middlewareBody via dynamic import', async () => { + mockMiddlewareBody.mockResolvedValue({ response: new Response() }); - it('provides correct context shape to downstream handlers', async () => { - const mockAuth = { user: { id: 'user_123' }, sessionId: 'session_123' }; - mockAuthkit.withAuth.mockResolvedValue({ - auth: mockAuth, - refreshedSessionData: null, - }); + authkitMiddleware(); - authkitMiddleware(); + const mockArgs = { request: new Request('http://test.local'), next: vi.fn() }; + await middlewareServerCallback(mockArgs); - const mockRequest = new Request('http://test.local'); - const mockResponse = new Response('OK', { status: 200 }); - - let capturedContext: any = null; - const args = { - request: mockRequest, - next: vi.fn(async ({ context }: any) => { - capturedContext = context; - return { response: mockResponse }; - }), - }; - - await middlewareServerCallback(args); - - expect(capturedContext.request).toBe(mockRequest); - expect(typeof capturedContext.auth).toBe('function'); - expect(capturedContext.auth()).toBe(mockAuth); - expect(typeof capturedContext.__setPendingHeader).toBe('function'); - }); - - it('preserves existing response headers', async () => { - mockAuthkit.withAuth.mockResolvedValue({ - auth: { user: null }, - refreshedSessionData: null, - }); - - authkitMiddleware(); - - const mockRequest = new Request('http://test.local'); - const mockResponse = new Response('OK', { - status: 200, - headers: { 'X-Existing': 'preserved' }, - }); - - const args = { - request: mockRequest, - next: vi.fn(async ({ context }: any) => { - context.__setPendingHeader('X-New', 'added'); - return { response: mockResponse }; - }), - }; - - const result = await middlewareServerCallback(args); - - expect(result.response.headers.get('X-Existing')).toBe('preserved'); - expect(result.response.headers.get('X-New')).toBe('added'); - }); + expect(mockMiddlewareBody).toHaveBeenCalledWith(mockArgs, undefined); }); - describe('redirectUri option', () => { - it('passes redirectUri to context when provided', async () => { - mockAuthkit.withAuth.mockResolvedValue({ - auth: { user: null }, - refreshedSessionData: null, - }); - - authkitMiddleware({ redirectUri: 'https://custom.example.com/callback' }); - - const mockRequest = new Request('http://test.local'); - const mockResponse = new Response('OK', { status: 200 }); - - let capturedContext: any = null; - const args = { - request: mockRequest, - next: vi.fn(async ({ context }: any) => { - capturedContext = context; - return { response: mockResponse }; - }), - }; - - await middlewareServerCallback(args); - - expect(capturedContext.redirectUri).toBe('https://custom.example.com/callback'); - }); - - it('passes undefined redirectUri when not provided', async () => { - mockAuthkit.withAuth.mockResolvedValue({ - auth: { user: null }, - refreshedSessionData: null, - }); - mockGetConfig.mockResolvedValue(undefined); - - authkitMiddleware(); - - const mockRequest = new Request('http://test.local'); - const mockResponse = new Response('OK', { status: 200 }); - - let capturedContext: any = null; - const args = { - request: mockRequest, - next: vi.fn(async ({ context }: any) => { - capturedContext = context; - return { response: mockResponse }; - }), - }; - - await middlewareServerCallback(args); - - expect(capturedContext.redirectUri).toBeUndefined(); - }); - - it('uses WORKOS_REDIRECT_URI from config when option not provided', async () => { - const envRedirectUri = 'https://env.example.com/callback'; - mockAuthkit.withAuth.mockResolvedValue({ - auth: { user: null }, - refreshedSessionData: null, - }); - mockGetConfig.mockResolvedValue(envRedirectUri); - - authkitMiddleware(); - - const mockRequest = new Request('http://test.local'); - const mockResponse = new Response('OK', { status: 200 }); - - let capturedContext: any = null; - const args = { - request: mockRequest, - next: vi.fn(async ({ context }: any) => { - capturedContext = context; - return { response: mockResponse }; - }), - }; - - await middlewareServerCallback(args); - - expect(capturedContext.redirectUri).toBe(envRedirectUri); - }); - - it('prioritizes explicit option over config', async () => { - const explicitRedirectUri = 'https://explicit.example.com/callback'; - mockAuthkit.withAuth.mockResolvedValue({ - auth: { user: null }, - refreshedSessionData: null, - }); - mockGetConfig.mockResolvedValue('https://env.example.com/callback'); - - authkitMiddleware({ redirectUri: explicitRedirectUri }); - - const mockRequest = new Request('http://test.local'); - const mockResponse = new Response('OK', { status: 200 }); + it('passes options through to middlewareBody', async () => { + mockMiddlewareBody.mockResolvedValue({ response: new Response() }); - let capturedContext: any = null; - const args = { - request: mockRequest, - next: vi.fn(async ({ context }: any) => { - capturedContext = context; - return { response: mockResponse }; - }), - }; + const options = { redirectUri: 'https://custom.example.com/callback' }; + authkitMiddleware(options); - await middlewareServerCallback(args); + const mockArgs = { request: new Request('http://test.local'), next: vi.fn() }; + await middlewareServerCallback(mockArgs); - expect(capturedContext.redirectUri).toBe(explicitRedirectUri); - }); + expect(mockMiddlewareBody).toHaveBeenCalledWith(mockArgs, options); }); }); diff --git a/src/server/middleware.ts b/src/server/middleware.ts index a7a1090..7c26255 100644 --- a/src/server/middleware.ts +++ b/src/server/middleware.ts @@ -1,7 +1,4 @@ import { createMiddleware } from '@tanstack/react-start'; -import { getAuthkit, validateConfig, getConfig } from './authkit-loader.js'; - -let configValidated = false; /** * Options for AuthKit middleware. @@ -38,63 +35,7 @@ export interface AuthKitMiddlewareOptions { */ export const authkitMiddleware = (options?: AuthKitMiddlewareOptions) => { return createMiddleware().server(async (args) => { - const authkit = await getAuthkit(); - - if (!configValidated) { - await validateConfig(); - configValidated = true; - } - - const { auth, refreshedSessionData } = await authkit.withAuth(args.request); - const pendingHeaders = new Headers(); - - const result = await args.next({ - context: { - auth: () => auth, - request: args.request, - redirectUri: options?.redirectUri ?? (await getConfig('redirectUri')), - __setPendingHeader: (key: string, value: string) => { - // Use append for Set-Cookie to support multiple cookies - if (key.toLowerCase() === 'set-cookie') { - pendingHeaders.append(key, value); - } else { - pendingHeaders.set(key, value); - } - }, - }, - }); - - // Apply refreshed session cookie. Context is unavailable after args.next(), - // so saveSession returns headers on the response instead of via context. - if (refreshedSessionData) { - const { response: sessionResponse } = await authkit.saveSession(undefined, refreshedSessionData); - // Extract Set-Cookie headers from response and add to pendingHeaders - const setCookieHeader = sessionResponse?.headers.get('Set-Cookie'); - if (setCookieHeader) { - pendingHeaders.append('Set-Cookie', setCookieHeader); - } - } - - const headerEntries = [...pendingHeaders]; - if (headerEntries.length === 0) { - return result; - } - - const newResponse = new Response(result.response.body, { - status: result.response.status, - statusText: result.response.statusText, - headers: result.response.headers, - }); - - for (const [key, value] of headerEntries) { - // Use append for Set-Cookie to preserve multiple cookie values - if (key.toLowerCase() === 'set-cookie') { - newResponse.headers.append(key, value); - } else { - newResponse.headers.set(key, value); - } - } - - return { ...result, response: newResponse }; + const { middlewareBody } = await import('./middleware-body.js'); + return middlewareBody(args, options); }); }; From b63a02c01df6f322a3bb4c0f8443756924642dc8 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 14 May 2026 12:38:00 -0500 Subject: [PATCH 3/4] Apply suggestions from code review Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .oxlintrc.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 071f3aa..594d019 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -23,7 +23,7 @@ "./middleware-body*" ], "allowTypeImports": true, - "message": "Static value imports of server-only modules are forbidden in this file. Move logic into action-bodies.ts or server-fn-bodies.ts and use a dynamic import inside the handler. See CLAUDE.md." + "message": "Static value imports of server-only modules are forbidden in this file. Move logic into action-bodies.ts, server-fn-bodies.ts, or middleware-body.ts and use a dynamic import inside the handler. See CLAUDE.md." }, { "group": ["@workos/authkit-session", "@workos-inc/node"], From 3b923a1dac503226260991d18ca0df8e9709c602 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 14 May 2026 14:34:00 -0500 Subject: [PATCH 4/4] fix: address PR review feedback - Move AuthKitMiddlewareOptions to shared types.ts to break bidirectional dependency between middleware.ts and middleware-body.ts - Use getSetCookie() instead of get('Set-Cookie') to correctly handle cookies containing commas - Fix saveSession mock shape to match production code (destructures { response } not { headers }) and assert Set-Cookie reaches response - Update oxlint error message to include middleware-body.ts as target --- src/server/middleware-body.spec.ts | 7 +++++-- src/server/middleware-body.ts | 7 +++---- src/server/middleware.ts | 12 ++---------- src/server/types.ts | 11 +++++++++++ 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/server/middleware-body.spec.ts b/src/server/middleware-body.spec.ts index 26e08bc..524785b 100644 --- a/src/server/middleware-body.spec.ts +++ b/src/server/middleware-body.spec.ts @@ -120,7 +120,9 @@ describe('middlewareBody', () => { refreshedSessionData: refreshedData, }); mockAuthkit.saveSession.mockResolvedValue({ - headers: { 'Set-Cookie': 'wos-session=new_value; Path=/' }, + response: new Response('', { + headers: { 'Set-Cookie': 'wos-session=new_value; Path=/' }, + }), }); const mockRequest = new Request('http://test.local'); @@ -131,9 +133,10 @@ describe('middlewareBody', () => { next: vi.fn(async () => ({ response: mockResponse })), }; - await middlewareBody(args); + const result = await middlewareBody(args); expect(mockAuthkit.saveSession).toHaveBeenCalledWith(undefined, refreshedData); + expect(result.response.headers.get('Set-Cookie')).toContain('wos-session=new_value'); }); it('provides correct context shape to downstream handlers', async () => { diff --git a/src/server/middleware-body.ts b/src/server/middleware-body.ts index 98bd53a..9aa9776 100644 --- a/src/server/middleware-body.ts +++ b/src/server/middleware-body.ts @@ -1,5 +1,5 @@ import { getAuthkit, validateConfig, getConfig } from './authkit-loader.js'; -import type { AuthKitMiddlewareOptions } from './middleware.js'; +import type { AuthKitMiddlewareOptions } from './types.js'; let configValidated = false; @@ -32,9 +32,8 @@ export async function middlewareBody(args: any, options?: AuthKitMiddlewareOptio if (refreshedSessionData) { const { response: sessionResponse } = await authkit.saveSession(undefined, refreshedSessionData); - const setCookieHeader = sessionResponse?.headers.get('Set-Cookie'); - if (setCookieHeader) { - pendingHeaders.append('Set-Cookie', setCookieHeader); + for (const cookie of sessionResponse?.headers.getSetCookie() ?? []) { + pendingHeaders.append('Set-Cookie', cookie); } } diff --git a/src/server/middleware.ts b/src/server/middleware.ts index 7c26255..d934186 100644 --- a/src/server/middleware.ts +++ b/src/server/middleware.ts @@ -1,15 +1,7 @@ import { createMiddleware } from '@tanstack/react-start'; +import type { AuthKitMiddlewareOptions } from './types.js'; -/** - * Options for AuthKit middleware. - */ -export interface AuthKitMiddlewareOptions { - /** - * Override the default redirect URI for OAuth callbacks. - * Useful for dynamic environments like Vercel preview deployments. - */ - redirectUri?: string; -} +export type { AuthKitMiddlewareOptions }; /** * AuthKit middleware for TanStack Start. diff --git a/src/server/types.ts b/src/server/types.ts index 3393c6f..0b28d59 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -1,5 +1,16 @@ import type { User, Impersonator } from '../types.js'; +/** + * Options for AuthKit middleware. + */ +export interface AuthKitMiddlewareOptions { + /** + * Override the default redirect URI for OAuth callbacks. + * Useful for dynamic environments like Vercel preview deployments. + */ + redirectUri?: string; +} + /** * OAuth tokens from upstream identity provider (e.g., Google, Microsoft) * Structure varies by provider but typically includes access_token and optional refresh_token