Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 4 additions & 3 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -19,10 +19,11 @@
"./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."
"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"],
Expand Down
1 change: 1 addition & 0 deletions scripts/check-bundle-leak.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
294 changes: 294 additions & 0 deletions src/server/middleware-body.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
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({
response: new Response('', {
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 })),
};

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 () => {
const mockAuth = { user: { id: 'user_123' }, sessionId: 'session_123' };
mockAuthkit.withAuth.mockResolvedValue({
auth: mockAuth,
refreshedSessionData: null,
});

Comment on lines +129 to +148
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 saveSession mock shape doesn't match what the production code reads

saveSession is mocked to resolve with { headers: { 'Set-Cookie': '...' } }, but middlewareBody destructures { response: sessionResponse } from that return value (line 34 of middleware-body.ts). Since response is absent from the mock, sessionResponse is always undefined, sessionResponse?.headers.get('Set-Cookie') returns undefined, and the cookie is never appended — so this test never actually verifies that the refreshed-session Set-Cookie reaches the final response. The mock should resolve with { response: new Response('', { headers: { 'Set-Cookie': 'wos-session=new_value; Path=/' } }) } to exercise that path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep. The mock here should be

mockAuthkit.saveSession.mockResolvedValue({
  response: new Response('', {
    headers: { 'Set-Cookie': 'wos-session=new_value; Path=/' },
  }),
});

and then later

  const result = await middlewareBody(args);
  expect(result.response.headers.get('Set-Cookie')).toContain('wos-session=new_value');

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3b923a1 — corrected the mock to return { response: new Response(...) } and added an assertion that Set-Cookie reaches the final response.

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);
});
});
});
60 changes: 60 additions & 0 deletions src/server/middleware-body.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { getAuthkit, validateConfig, getConfig } from './authkit-loader.js';
import type { AuthKitMiddlewareOptions } from './types.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);
for (const cookie of sessionResponse?.headers.getSetCookie() ?? []) {
pendingHeaders.append('Set-Cookie', cookie);
}
}

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 };
}
Loading
Loading