Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
---

Hardened how request authentication is resolved so a reused React Router `context` can no longer leak one user's auth to another. `getAuth()` and `rootAuthLoader()` now resolve auth for the current request keyed to that request, rather than reading a value the middleware cached on the context. If the context is ever reused across requests, whether from a custom server, a shared `getLoadContext`, or a serverless adapter that reuses it on a warm instance, requests no longer cross-contaminate, including across React Router's action-to-loader revalidation. Auth is still resolved once per request and reused, so token verification and refresh happen a single time. `clerkMiddleware()` also logs a one-time warning when it detects a context reused across requests.
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// 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');
});

// The point of resolving once: the middleware authenticates, and repeat getAuth
// calls on the same request reuse that result instead of re-authenticating
// (so machine-token verification / refresh happen once per request, not per call).
it('authenticates once per request and reuses it across getAuth calls', async () => {
const authSpy = vi.fn((req: { url: string }) => Promise.resolve(fakeStateForRequest(req)));
mockClerkClient.mockReturnValue({ authenticateRequest: authSpy } as unknown as ClerkClient);

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

await middleware(args, async () => {
expect(await readUserId(args)).toBe('user_A');
expect(await readUserId(args)).toBe('user_A');
return new Response('A');
});

// Only the middleware's authenticateRequest ran; the two getAuth calls reused it.
expect(authSpy).toHaveBeenCalledTimes(1);
});

// On a Request-instance miss (action -> loader), it re-derives exactly once.
it('re-derives once on a fresh Request instance', async () => {
const authSpy = vi.fn((req: { url: string }) => Promise.resolve(fakeStateForRequest(req)));
mockClerkClient.mockReturnValue({ authenticateRequest: authSpy } as unknown as ClerkClient);

const middleware = clerkMiddleware();
const request = new Request('http://app.test/?u=user_A', { method: 'POST' });
const args = { request, context: new RouterContextProvider() } as unknown as LoaderFunctionArgs;

await middleware(args, async () => {
const loaderRequest = new Request(request.url, { headers: request.headers });
const loaderArgs = { request: loaderRequest, context: args.context } as unknown as LoaderFunctionArgs;
expect(await readUserId(loaderArgs)).toBe('user_A');
return new Response('A');
});

// middleware (1) + one re-derive for the fresh loader Request (1).
expect(authSpy).toHaveBeenCalledTimes(2);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
});
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
Loading
Loading