Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/rr-request-scoped-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/react-router': patch
---

Fixed a cross-user authentication issue for apps that share a single React Router `context` across requests (for example a custom server or `getLoadContext` that returns one `RouterContextProvider` instance). `getAuth()` and `rootAuthLoader()` now resolve the current request's auth from that request rather than from a value cached on the context, so a shared context can no longer cause one request to be served another user's auth under concurrency, including across React Router's action-to-loader revalidation. `clerkMiddleware()` also logs a warning once when it detects a context reused across requests, since a shared context can still leak an application's own per-request data.
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// getAuth re-derives auth from `args.request`, so it returns the right user even
// when an app shares one RouterContextProvider across requests. authenticateRequest
// is mocked to resolve each request to the user encoded in its URL (?u=...).
import type { ClerkClient } from '@clerk/backend';
import { AuthStatus, TokenType } from '@clerk/backend/internal';
import type { LoaderFunctionArgs } from 'react-router';
import { RouterContextProvider } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { clerkClient } from '../clerkClient';
import { clerkMiddleware } from '../clerkMiddleware';
import { getAuth } from '../getAuth';
import { loadOptions } from '../loadOptions';

vi.mock('../clerkClient');
vi.mock('../loadOptions');

const mockClerkClient = vi.mocked(clerkClient);
const mockLoadOptions = vi.mocked(loadOptions);

function fakeStateForRequest(req: { url: string }) {
const userId = new URL(req.url).searchParams.get('u');
return {
status: AuthStatus.SignedIn,
headers: new Headers(),
publishableKey: 'pk_live_xxx',
toAuth: () => ({ userId, tokenType: TokenType.SessionToken }),
};
}

const flushMicrotasks = () => new Promise<void>(resolve => setTimeout(resolve, 0));

async function readUserId(args: LoaderFunctionArgs): Promise<string | null | undefined> {
const auth = (await getAuth(args, { acceptsToken: 'any' })) as { userId?: string | null };
return auth.userId;
}

describe('clerkMiddleware + getAuth auth isolation', () => {
beforeEach(() => {
vi.clearAllMocks();
mockLoadOptions.mockReturnValue({
audience: '',
authorizedParties: [],
signInUrl: '',
signUpUrl: '',
secretKey: 'sk_live_xxx',
// pk_live -> production instance -> shared-context probe warns (does not throw).
publishableKey: 'pk_live_xxx',
} as unknown as ReturnType<typeof loadOptions>);
mockClerkClient.mockReturnValue({
authenticateRequest: vi.fn((req: { url: string }) => Promise.resolve(fakeStateForRequest(req))),
} as unknown as ClerkClient);
});

// Interleave two concurrent requests, each using `contextFor(request)`:
// 1. A's middleware runs, then parks inside next().
// 2. B's middleware runs, B's loader reads its own auth in next().
// 3. A unparks and reads its auth.
async function runInterleaved(contextFor: (req: Request) => RouterContextProvider) {
const middleware = clerkMiddleware();
const results: { A?: string | null; B?: string | null } = {};

let releaseA!: () => void;
const gateA = new Promise<void>(resolve => (releaseA = resolve));

const reqA = new Request('http://app.test/?u=user_A');
const reqB = new Request('http://app.test/?u=user_B');
const argsA = { request: reqA, context: contextFor(reqA) } as unknown as LoaderFunctionArgs;
const argsB = { request: reqB, context: contextFor(reqB) } as unknown as LoaderFunctionArgs;

const aDone = middleware(argsA, async () => {
await gateA;
results.A = await readUserId(argsA);
return new Response('A');
});

await flushMicrotasks();

await middleware(argsB, async () => {
results.B = await readUserId(argsB);
return new Response('B');
});

releaseA();
await aDone;

return results;
}

it('keeps auth per-request with a shared RouterContextProvider', async () => {
const shared = new RouterContextProvider();
const results = await runInterleaved(() => shared);

expect(results.A).toBe('user_A');
expect(results.B).toBe('user_B');
});

it('keeps auth per-request with a fresh RouterContextProvider per request', async () => {
const perRequest = new Map<Request, RouterContextProvider>();
const results = await runInterleaved(req => {
if (!perRequest.has(req)) {
perRequest.set(req, new RouterContextProvider());
}
return perRequest.get(req)!;
});

expect(results.A).toBe('user_A');
expect(results.B).toBe('user_B');
});

// React Router mints a NEW Request for post-action loader revalidation. getAuth
// re-derives from whatever request the loader was invoked with, so it resolves
// the right user even reading via the fresh Request on a shared context.
it('resolves the right user when the loader reads via a fresh Request (action -> loader)', async () => {
const shared = new RouterContextProvider();
const middleware = clerkMiddleware();

const reqA = new Request('http://app.test/?u=user_A', { method: 'POST' });
const argsA = { request: reqA, context: shared } as unknown as LoaderFunctionArgs;

let seen: string | null | undefined;
await middleware(argsA, async () => {
const loaderRequest = new Request(reqA.url, { headers: reqA.headers });
const loaderArgs = { request: loaderRequest, context: shared } as unknown as LoaderFunctionArgs;
seen = await readUserId(loaderArgs);
return new Response('A');
});

expect(seen).toBe('user_A');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// clerkMiddleware warns once when it detects a React Router context reused across
// requests (the shared-RouterContextProvider footgun). We spy on logger.warnOnce
// so assertions don't depend on its per-process dedup.
import type { ClerkClient } from '@clerk/backend';
import { AuthStatus, TokenType } from '@clerk/backend/internal';
import { logger } from '@clerk/shared/logger';
import type { LoaderFunctionArgs } from 'react-router';
import { RouterContextProvider } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { clerkClient } from '../clerkClient';
import { clerkMiddleware } from '../clerkMiddleware';
import { loadOptions } from '../loadOptions';

vi.mock('../clerkClient');
vi.mock('../loadOptions');

const mockClerkClient = vi.mocked(clerkClient);
const mockLoadOptions = vi.mocked(loadOptions);

function fakeStateForRequest(req: { url: string }) {
const userId = new URL(req.url).searchParams.get('u');
return {
status: AuthStatus.SignedIn,
headers: new Headers(),
publishableKey: 'pk',
toAuth: () => ({ userId, tokenType: TokenType.SessionToken }),
};
}

const noop = () => Promise.resolve(new Response('ok'));

describe('clerkMiddleware shared-context detection', () => {
let warnOnceSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
vi.clearAllMocks();
warnOnceSpy = vi.spyOn(logger, 'warnOnce').mockImplementation(() => {});
mockLoadOptions.mockReturnValue({
audience: '',
authorizedParties: [],
signInUrl: '',
signUpUrl: '',
secretKey: 'sk_live_xxx',
publishableKey: 'pk_live_xxx',
} as unknown as ReturnType<typeof loadOptions>);
mockClerkClient.mockReturnValue({
authenticateRequest: vi.fn((req: { url: string }) => Promise.resolve(fakeStateForRequest(req))),
} as unknown as ClerkClient);
});

it('warns once when two requests share one RouterContextProvider', async () => {
const middleware = clerkMiddleware();
const shared = new RouterContextProvider();

await middleware(
{ request: new Request('http://app.test/?u=user_A'), context: shared } as unknown as LoaderFunctionArgs,
noop,
);
await middleware(
{ request: new Request('http://app.test/?u=user_B'), context: shared } as unknown as LoaderFunctionArgs,
noop,
);

expect(warnOnceSpy).toHaveBeenCalledTimes(1);
expect(warnOnceSpy).toHaveBeenCalledWith(expect.stringContaining('reused across requests'));
});

it('does not warn when each request gets its own RouterContextProvider', async () => {
const middleware = clerkMiddleware();

await middleware(
{
request: new Request('http://app.test/?u=user_A'),
context: new RouterContextProvider(),
} as unknown as LoaderFunctionArgs,
noop,
);
await middleware(
{
request: new Request('http://app.test/?u=user_B'),
context: new RouterContextProvider(),
} as unknown as LoaderFunctionArgs,
noop,
);

expect(warnOnceSpy).not.toHaveBeenCalled();
});
});
24 changes: 16 additions & 8 deletions packages/react-router/src/server/__tests__/getAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,32 @@ import { TokenType } from '@clerk/backend/internal';
import type { LoaderFunctionArgs } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { authFnContext } from '../clerkMiddleware';
import { clerkClient } from '../clerkClient';
import { requestOptionsContext } from '../clerkMiddleware';
import { getAuth } from '../getAuth';

vi.mock('../clerkClient');
const mockClerkClient = vi.mocked(clerkClient);

describe('getAuth', () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.CLERK_SECRET_KEY = 'sk_test_...';
mockClerkClient.mockReturnValue({
authenticateRequest: vi.fn().mockResolvedValue({
headers: new Headers(),
toAuth: (options?: any) => ({ userId: 'user_xxx', tokenType: TokenType.SessionToken, ...options }),
}),
} as any);
});

it('should work when middleware context exists', async () => {
it('should re-derive auth from the request when middleware ran', async () => {
// Middleware stashes identity-free options; getAuth re-derives the user from
// the request via authenticateRequest rather than reading a cached value.
const mockContext = {
get: vi.fn().mockImplementation(contextKey => {
if (contextKey === authFnContext) {
return vi.fn().mockImplementation((options?: any) => ({
userId: 'user_xxx',
tokenType: TokenType.SessionToken,
...options,
}));
if (contextKey === requestOptionsContext) {
return { secretKey: 'sk_test_...', publishableKey: 'pk_test_...', acceptsToken: 'any' };
}
return null;
}),
Expand Down
81 changes: 35 additions & 46 deletions packages/react-router/src/server/__tests__/rootAuthLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,49 +2,54 @@ import { TokenType } from '@clerk/backend/internal';
import { data, type LoaderFunctionArgs } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { authFnContext, requestStateContext } from '../clerkMiddleware';
import { clerkClient } from '../clerkClient';
import { authFnContext, requestOptionsContext, requestStateContext } from '../clerkMiddleware';
import { rootAuthLoader } from '../rootAuthLoader';

vi.mock('../clerkClient');
const mockClerkClient = vi.mocked(clerkClient);

describe('rootAuthLoader', () => {
const mockRequestState = {
toAuth: vi.fn().mockImplementation(() => ({
userId: 'user_xxx',
tokenType: TokenType.SessionToken,
})),
headers: new Headers(),
status: 'signed-in',
};

beforeEach(() => {
vi.clearAllMocks();
process.env.CLERK_SECRET_KEY = 'sk_test_...';
// rootAuthLoader re-derives the request state from the request rather than
// reading the value cached on the context.
mockClerkClient.mockReturnValue({
authenticateRequest: vi.fn().mockResolvedValue(mockRequestState),
} as any);
});

describe('with middleware context', () => {
const mockRequestState = {
toAuth: vi.fn().mockImplementation(() => ({
userId: 'user_xxx',
tokenType: TokenType.SessionToken,
})),
headers: new Headers(),
status: 'signed-in',
};

const mockContext = {
const makeContext = (additionalState: Record<string, unknown> = {}) => ({
get: vi.fn().mockImplementation(contextKey => {
if (contextKey === requestOptionsContext) {
return { secretKey: 'sk_test_...', publishableKey: 'pk_test_...', acceptsToken: 'any' };
}
if (contextKey === requestStateContext) {
return {
requestState: mockRequestState,
additionalState: {},
};
return { requestState: mockRequestState, additionalState };
}
if (contextKey === authFnContext) {
return vi.fn().mockImplementation((options?: any) => ({
userId: 'user_xxx',
tokenType: TokenType.SessionToken,
...options,
}));
return vi.fn().mockReturnValue({ userId: 'user_xxx', tokenType: TokenType.SessionToken });
}
return null;
}),
set: vi.fn(),
};
});

const args = {
context: mockContext,
context: makeContext(),
request: new Request('http://clerk.com'),
} as LoaderFunctionArgs;
} as unknown as LoaderFunctionArgs;

it('should work with a callback', async () => {
await rootAuthLoader(args, () => ({ data: 'test' }));
Expand Down Expand Up @@ -108,31 +113,15 @@ describe('rootAuthLoader', () => {
});

it('should forward redirect URL options from additionalState into clerkState', async () => {
const mockContext2 = {
get: vi.fn().mockImplementation(contextKey => {
if (contextKey === requestStateContext) {
return {
requestState: mockRequestState,
additionalState: {
signInForceRedirectUrl: '/dashboard',
signUpForceRedirectUrl: '/welcome',
signInFallbackRedirectUrl: '/home',
signUpFallbackRedirectUrl: '/home',
},
};
}
if (contextKey === authFnContext) {
return vi.fn().mockReturnValue({ userId: 'user_xxx', tokenType: TokenType.SessionToken });
}
return null;
}),
set: vi.fn(),
};

const result = (await rootAuthLoader({
context: mockContext2,
context: makeContext({
signInForceRedirectUrl: '/dashboard',
signUpForceRedirectUrl: '/welcome',
signInFallbackRedirectUrl: '/home',
signUpFallbackRedirectUrl: '/home',
}),
request: new Request('http://clerk.com'),
} as LoaderFunctionArgs)) as any;
} as unknown as LoaderFunctionArgs)) as any;

const internalState = result.clerkState.__internal_clerk_state;
expect(internalState.__signInForceRedirectUrl).toBe('/dashboard');
Expand Down
Loading
Loading