diff --git a/.changeset/quiet-bats-protect.md b/.changeset/quiet-bats-protect.md new file mode 100644 index 00000000000..73f732b19fe --- /dev/null +++ b/.changeset/quiet-bats-protect.md @@ -0,0 +1,54 @@ +--- +'@clerk/react-router': major +--- + +Introduce [React Router middleware](https://reactrouter.com/how-to/middleware) support with `clerkMiddleware()` for improved performance and streaming capabilities. + +Usage of `rootAuthLoader` without the `clerkMiddleware()` installed is now deprecated and will be removed in the next major version. + +**Before (Deprecated - will be removed):** + +```tsx +import { rootAuthLoader } from '@clerk/react-router/ssr.server' + +export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args) +``` + +**After (Recommended):** + +1. Enable the `v8_middleware` future flag: + +```ts +// react-router.config.ts +export default { + future: { + v8_middleware: true, + }, +} satisfies Config; +``` + +2. Use the middleware in your app: + +```tsx +import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server' + +export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()] + +export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args) +``` + +**Streaming Support (with middleware):** + +```tsx +export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()] + +export const loader = (args: Route.LoaderArgs) => { + const nonCriticalData = new Promise((res) => + setTimeout(() => res('non-critical'), 5000), + ) + + return rootAuthLoader(args, () => ({ + nonCriticalData + })) +} +``` \ No newline at end of file diff --git a/integration/presets/utils.ts b/integration/presets/utils.ts index 2d5d7a87414..f7831c39663 100644 --- a/integration/presets/utils.ts +++ b/integration/presets/utils.ts @@ -2,7 +2,9 @@ import path from 'node:path'; export function linkPackage(pkg: string) { // eslint-disable-next-line turbo/no-undeclared-env-vars - if (process.env.CI === 'true') return '*'; + if (process.env.CI === 'true') { + return '*'; + } return `link:${path.resolve(process.cwd(), `packages/${pkg}`)}`; } diff --git a/integration/templates/react-router-library/package.json b/integration/templates/react-router-library/package.json index 28391f861f1..4febd9a0dee 100644 --- a/integration/templates/react-router-library/package.json +++ b/integration/templates/react-router-library/package.json @@ -9,15 +9,14 @@ "preview": "vite preview --port $PORT" }, "dependencies": { - "@clerk/react-router": "^0.1.2", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router": "^7.1.2" + "react-router": "^7.9.1" }, "devDependencies": { "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.4", + "@vitejs/plugin-react": "^5.0.3", "globals": "^15.12.0", "typescript": "~5.7.3", "vite": "^6.0.1" diff --git a/integration/templates/react-router-node/app/root.tsx b/integration/templates/react-router-node/app/root.tsx index 0bae3ebbc62..e24f3b1a918 100644 --- a/integration/templates/react-router-node/app/root.tsx +++ b/integration/templates/react-router-node/app/root.tsx @@ -1,9 +1,11 @@ import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'; import { rootAuthLoader } from '@clerk/react-router/ssr.server'; import { ClerkProvider } from '@clerk/react-router'; - import type { Route } from './+types/root'; +// TODO: Uncomment when published +// export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]; + export async function loader(args: Route.LoaderArgs) { return rootAuthLoader(args); } diff --git a/integration/templates/react-router-node/app/routes/protected.tsx b/integration/templates/react-router-node/app/routes/protected.tsx index 2fdc2718e1c..362fcac4fa4 100644 --- a/integration/templates/react-router-node/app/routes/protected.tsx +++ b/integration/templates/react-router-node/app/routes/protected.tsx @@ -14,7 +14,8 @@ export async function loader(args: Route.LoaderArgs) { const user = await createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY }).users.getUser(userId); return { - user, + firstName: user.firstName, + emailAddress: user.emailAddresses[0].emailAddress, }; } @@ -24,8 +25,8 @@ export default function Profile({ loaderData }: Route.ComponentProps) {

Protected

); diff --git a/integration/templates/react-router-node/package.json b/integration/templates/react-router-node/package.json index aabe6a20c32..3bcf6de6ba8 100644 --- a/integration/templates/react-router-node/package.json +++ b/integration/templates/react-router-node/package.json @@ -9,21 +9,20 @@ "typecheck": "react-router typegen && tsc --build --noEmit" }, "dependencies": { - "@clerk/react-router": "latest", - "@react-router/node": "^7.1.2", - "@react-router/serve": "^7.1.2", + "@react-router/node": "^7.9.1", + "@react-router/serve": "^7.9.1", "isbot": "^5.1.17", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router": "^7.1.2" + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router": "^7.9.1" }, "devDependencies": { - "@react-router/dev": "^7.1.2", + "@react-router/dev": "^7.9.1", "@types/node": "^20", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", "typescript": "^5.7.3", - "vite": "^5.4.11", - "vite-tsconfig-paths": "^5.1.2" + "vite": "^7.1.5", + "vite-tsconfig-paths": "^5.1.4" } } diff --git a/integration/templates/react-router-node/react-router.config.ts b/integration/templates/react-router-node/react-router.config.ts index 4f9a6ed5228..77f1c2cbc06 100644 --- a/integration/templates/react-router-node/react-router.config.ts +++ b/integration/templates/react-router-node/react-router.config.ts @@ -4,4 +4,8 @@ export default { // Config options... // Server-side render by default, to enable SPA mode set this to `false` ssr: true, + future: { + v8_middleware: true, + unstable_optimizeDeps: true, + }, } satisfies Config; diff --git a/integration/tests/react-router/basic.test.ts b/integration/tests/react-router/basic.test.ts index 595a724304b..e67921ef416 100644 --- a/integration/tests/react-router/basic.test.ts +++ b/integration/tests/react-router/basic.test.ts @@ -5,7 +5,7 @@ import type { FakeUser } from '../../testUtils'; import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes], withPattern: ['react-router.node'] })( - 'basic tests for @react-router', + 'basic tests for @react-router with middleware', ({ app }) => { test.describe.configure({ mode: 'parallel' }); diff --git a/integration/tests/react-router/pre-middleware.test.ts b/integration/tests/react-router/pre-middleware.test.ts new file mode 100644 index 00000000000..3cb80691d2d --- /dev/null +++ b/integration/tests/react-router/pre-middleware.test.ts @@ -0,0 +1,169 @@ +import { expect, test } from '@playwright/test'; + +import type { Application } from '../../models/application'; +import { appConfigs } from '../../presets'; +import type { FakeUser } from '../../testUtils'; +import { createTestUtils } from '../../testUtils'; + +test.describe('basic tests for @react-router without middleware', () => { + test.describe.configure({ mode: 'parallel' }); + let app: Application; + let fakeUser: FakeUser; + + test.beforeAll(async () => { + test.setTimeout(90_000); // Wait for app to be ready + app = await appConfigs.reactRouter.reactRouterNode + .clone() + .addFile( + `app/root.tsx`, + () => `import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'; +import { rootAuthLoader } from '@clerk/react-router/ssr.server'; +import { ClerkProvider } from '@clerk/react-router'; + +import type { Route } from './+types/root'; + +export async function loader(args: Route.LoaderArgs) { + return rootAuthLoader(args); +} + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ); +} + +export default function App({ loaderData }: Route.ComponentProps) { + return ( + +
+ +
+
+ ); +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + let message = 'Oops!'; + let details = 'An unexpected error occurred.'; + let stack: string | undefined; + + if (isRouteErrorResponse(error)) { + message = error.status === 404 ? '404' : 'Error'; + details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details; + } else if (import.meta.env.DEV && error && error instanceof Error) { + details = error.message; + stack = error.stack; + } + + return ( +
+

{message}

+

{details}

+ {stack && ( +
+          {stack}
+        
+ )} +
+ ); +} +`, + ) + .commit(); + + await app.setup(); + await app.withEnv(appConfigs.envs.withEmailCodes); + await app.dev(); + + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser({ + fictionalEmail: true, + withPhoneNumber: true, + withUsername: true, + }); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + test.afterEach(async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.signOut(); + await u.page.context().clearCookies(); + }); + + test('can sign in and user button renders', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + + await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.setPassword(fakeUser.password); + await u.po.signIn.continue(); + await u.po.expect.toBeSignedIn(); + + await u.page.waitForAppUrl('/'); + + await u.po.userButton.waitForMounted(); + await u.po.userButton.toggleTrigger(); + await u.po.userButton.waitForPopover(); + + await u.po.userButton.toHaveVisibleMenuItems([/Manage account/i, /Sign out$/i]); + }); + + test('redirects to sign-in when unauthenticated', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.page.goToRelative('/protected'); + await u.page.waitForURL(`${app.serverUrl}/sign-in`); + await u.po.signIn.waitForMounted(); + }); + + test('renders control components contents', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.page.goToAppHome(); + await expect(u.page.getByText('SignedOut')).toBeVisible(); + + await u.page.goToRelative('/sign-in'); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + await expect(u.page.getByText('SignedIn')).toBeVisible(); + }); + + test('renders user profile with SSR data', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.page.goToRelative('/sign-in'); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.po.userButton.waitForMounted(); + await u.page.goToRelative('/protected'); + await u.po.userProfile.waitForMounted(); + + // Fetched from an API endpoint (/api/me), which is server-rendered. + // This also verifies that the server middleware is working. + await expect(u.page.getByText(`First name: ${fakeUser.firstName}`)).toBeVisible(); + await expect(u.page.getByText(`Email: ${fakeUser.email}`)).toBeVisible(); + }); +}); diff --git a/package.json b/package.json index e824b83e4e4..ba85402ae45 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "test:integration:nextjs": "E2E_APP_ID=next.appRouter.* pnpm test:integration:base --grep @nextjs", "test:integration:nuxt": "E2E_APP_ID=nuxt.node npm run test:integration:base -- --grep @nuxt", "test:integration:quickstart": "E2E_APP_ID=quickstart.* pnpm test:integration:base --grep @quickstart", - "test:integration:react-router": "E2E_APP_ID=react-router.* npm run test:integration:base -- --grep @react-router", + "test:integration:react-router": "E2E_APP_ID=react-router.* pnpm test:integration:base --grep @react-router", "test:integration:sessions": "DISABLE_WEB_SECURITY=true E2E_SESSIONS_APP_1_ENV_KEY=sessions-prod-1 E2E_SESSIONS_APP_2_ENV_KEY=sessions-prod-2 E2E_SESSIONS_APP_1_HOST=multiple-apps-e2e.clerk.app pnpm test:integration:base --grep @sessions", "test:integration:sessions:staging": "DISABLE_WEB_SECURITY=true E2E_SESSIONS_APP_1_ENV_KEY=clerkstage-sessions-prod-1 E2E_SESSIONS_APP_2_ENV_KEY=clerkstage-sessions-prod-2 E2E_SESSIONS_APP_1_HOST=clerkstage-sessions-prod-1-e2e.clerk.app pnpm test:integration:base --grep @sessions", "test:integration:tanstack-react-router": "E2E_APP_ID=tanstack.react-router pnpm test:integration:base --grep @tanstack-react-router", diff --git a/packages/react-router/package.json b/packages/react-router/package.json index bdfa38997d0..9d0922abfc4 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -30,6 +30,10 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./server": { + "types": "./dist/server/index.d.ts", + "default": "./dist/server/index.js" + }, "./ssr.server": { "types": "./dist/ssr/index.d.ts", "default": "./dist/ssr/index.js" @@ -55,6 +59,9 @@ "dist/*.d.ts", "dist/index.d.ts" ], + "server": [ + "dist/server/index.d.ts" + ], "ssr.server": [ "dist/ssr/index.d.ts" ], @@ -97,7 +104,7 @@ "peerDependencies": { "react": "catalog:peer-react", "react-dom": "catalog:peer-react", - "react-router": "^7.1.2" + "react-router": "^7.9.0" }, "engines": { "node": ">=20.0.0" diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap index 84aae121b9f..53f3377ed94 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -1,5 +1,12 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`deprecated ssr public exports > should not change unexpectedly 1`] = ` +[ + "getAuth", + "rootAuthLoader", +] +`; + exports[`root public exports > should not change unexpectedly 1`] = ` [ "APIKeys", @@ -54,9 +61,13 @@ exports[`root public exports > should not change unexpectedly 1`] = ` ] `; -exports[`ssr public exports > should not change unexpectedly 1`] = ` +exports[`server public exports > should not change unexpectedly 1`] = ` [ + "clerkClient", + "clerkMiddleware", + "createClerkClient", "getAuth", "rootAuthLoader", + "verifyToken", ] `; diff --git a/packages/react-router/src/__tests__/exports.test.ts b/packages/react-router/src/__tests__/exports.test.ts index 4bba4ec2277..1d9551d96c7 100644 --- a/packages/react-router/src/__tests__/exports.test.ts +++ b/packages/react-router/src/__tests__/exports.test.ts @@ -1,5 +1,8 @@ +import { logger } from '@clerk/shared/logger'; +import { vi } from 'vitest'; + import * as publicExports from '../index'; -import * as ssrExports from '../ssr/index'; +import * as serverExports from '../server/index'; describe('root public exports', () => { it('should not change unexpectedly', () => { @@ -7,8 +10,18 @@ describe('root public exports', () => { }); }); -describe('ssr public exports', () => { +describe('server public exports', () => { it('should not change unexpectedly', () => { + expect(Object.keys(serverExports).sort()).toMatchSnapshot(); + }); +}); + +describe('deprecated ssr public exports', () => { + it('should not change unexpectedly', async () => { + const warnOnceSpy = vi.spyOn(logger, 'warnOnce').mockImplementation(() => {}); + const ssrExports = await import('../ssr/index'); expect(Object.keys(ssrExports).sort()).toMatchSnapshot(); + expect(warnOnceSpy).toHaveBeenCalled(); + warnOnceSpy.mockRestore(); }); }); diff --git a/packages/react-router/src/api/index.ts b/packages/react-router/src/api/index.ts index f5ce35a683b..cb3b0378663 100644 --- a/packages/react-router/src/api/index.ts +++ b/packages/react-router/src/api/index.ts @@ -1 +1,15 @@ export * from '@clerk/backend'; + +import { logger } from '@clerk/shared/logger'; + +logger.warnOnce(` +Clerk - DEPRECATION WARNING: \`@clerk/react-router/api.server\` has been deprecated and will be removed in the next major version. + +Import from \`@clerk/react-router/server\` instead. + +Before: + import { getAuth, clerkMiddleware, rootAuthLoader } from '@clerk/react-router/api.server'; + +After: + import { getAuth, clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server'; +`); diff --git a/packages/react-router/src/server/__tests__/clerkMiddleware.test.ts b/packages/react-router/src/server/__tests__/clerkMiddleware.test.ts new file mode 100644 index 00000000000..8be8e33c419 --- /dev/null +++ b/packages/react-router/src/server/__tests__/clerkMiddleware.test.ts @@ -0,0 +1,151 @@ +import type { ClerkClient } from '@clerk/backend'; +import { AuthStatus, TokenType } from '@clerk/backend/internal'; +import type { LoaderFunctionArgs } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { clerkClient } from '../clerkClient'; +import { authFnContext, clerkMiddleware, requestStateContext } from '../clerkMiddleware'; +import { loadOptions } from '../loadOptions'; +import type { ClerkMiddlewareOptions } from '../types'; + +vi.mock('../clerkClient'); +vi.mock('../loadOptions'); + +const mockClerkClient = vi.mocked(clerkClient); +const mockLoadOptions = vi.mocked(loadOptions); + +describe('clerkMiddleware', () => { + const mockNext = vi.fn(); + const mockContext = { + get: vi.fn(), + set: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.CLERK_SECRET_KEY = 'sk_test_...'; + + mockLoadOptions.mockReturnValue({ + audience: '', + authorizedParties: [], + signInUrl: '', + signUpUrl: '', + afterSignInUrl: '', + afterSignUpUrl: '', + secretKey: 'sk_test_...', + publishableKey: 'pk_test_...', + } as unknown as ReturnType); + + mockClerkClient.mockReturnValue({ + authenticateRequest: vi.fn(), + } as unknown as ClerkClient); + }); + + it('should authenticate request and set context', async () => { + const mockRequestState = { + status: AuthStatus.SignedIn, + headers: new Headers(), + toAuth: vi.fn().mockReturnValue({ userId: 'user_xxx', tokenType: TokenType.SessionToken }), + }; + + const mockAuthenticateRequest = vi.fn().mockResolvedValue(mockRequestState); + mockClerkClient.mockReturnValue({ + authenticateRequest: mockAuthenticateRequest, + } as unknown as ClerkClient); + + const middleware = clerkMiddleware(); + const args = { + request: new Request('http://clerk.com'), + context: mockContext, + } as LoaderFunctionArgs; + + const mockResponse = new Response('OK'); + mockNext.mockResolvedValue(mockResponse); + + const result = await middleware(args, mockNext); + + expect(mockAuthenticateRequest).toHaveBeenCalledWith(expect.any(Object), { + audience: '', + authorizedParties: [], + signInUrl: '', + signUpUrl: '', + afterSignInUrl: '', + afterSignUpUrl: '', + acceptsToken: 'any', + }); + + expect(mockContext.set).toHaveBeenCalledWith(authFnContext, expect.any(Function)); + expect(mockContext.set).toHaveBeenCalledWith(requestStateContext, mockRequestState); + + expect(mockNext).toHaveBeenCalled(); + + expect(result).toBe(mockResponse); + }); + + it('should pass options to loadOptions', async () => { + const mockRequestState = { + status: AuthStatus.SignedIn, + headers: new Headers(), + toAuth: vi.fn().mockReturnValue({ userId: 'user_xxx', tokenType: TokenType.SessionToken }), + }; + + const mockAuthenticateRequest = vi.fn().mockResolvedValue(mockRequestState); + mockClerkClient.mockReturnValue({ + authenticateRequest: mockAuthenticateRequest, + } as unknown as ClerkClient); + + const options: ClerkMiddlewareOptions = { + audience: 'test-audience', + authorizedParties: ['https://example.com'], + signInUrl: '/sign-in', + signUpUrl: '/sign-up', + afterSignInUrl: '/dashboard', + afterSignUpUrl: '/welcome', + }; + + const middleware = clerkMiddleware(options); + const args = { + request: new Request('http://clerk.com'), + context: mockContext, + } as LoaderFunctionArgs; + + const mockResponse = new Response('OK'); + mockNext.mockResolvedValue(mockResponse); + + await middleware(args, mockNext); + + expect(mockLoadOptions).toHaveBeenCalledWith(args, options); + }); + + it('should append request state headers to response', async () => { + const mockRequestState = { + status: AuthStatus.SignedIn, + headers: new Headers({ + 'x-clerk-auth-status': 'signed-in', + 'x-clerk-auth-reason': 'auth-reason', + 'x-clerk-auth-message': 'auth-message', + }), + toAuth: vi.fn().mockReturnValue({ userId: 'user_xxx', tokenType: TokenType.SessionToken }), + }; + + const mockAuthenticateRequest = vi.fn().mockResolvedValue(mockRequestState); + mockClerkClient.mockReturnValue({ + authenticateRequest: mockAuthenticateRequest, + } as unknown as ClerkClient); + + const middleware = clerkMiddleware(); + const args = { + request: new Request('http://clerk.com'), + context: mockContext, + } as LoaderFunctionArgs; + + const mockResponse = new Response('OK'); + mockNext.mockResolvedValue(mockResponse); + + const result = (await middleware(args, mockNext)) as Response; + + expect(result.headers.get('x-clerk-auth-status')).toBe('signed-in'); + expect(result.headers.get('x-clerk-auth-reason')).toBe('auth-reason'); + expect(result.headers.get('x-clerk-auth-message')).toBe('auth-message'); + }); +}); diff --git a/packages/react-router/src/server/__tests__/getAuth.test.ts b/packages/react-router/src/server/__tests__/getAuth.test.ts new file mode 100644 index 00000000000..742fc6f0ae1 --- /dev/null +++ b/packages/react-router/src/server/__tests__/getAuth.test.ts @@ -0,0 +1,71 @@ +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 { getAuth } from '../getAuth'; +import { legacyAuthenticateRequest } from '../legacyAuthenticateRequest'; + +vi.mock('../legacyAuthenticateRequest', () => { + return { + legacyAuthenticateRequest: vi.fn().mockResolvedValue({ + toAuth: vi.fn().mockImplementation(() => ({ + userId: 'user_xxx', + tokenType: TokenType.SessionToken, + })), + headers: new Headers(), + status: 'signed-in', + }), + }; +}); + +describe('getAuth', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.CLERK_SECRET_KEY = 'sk_test_...'; + }); + + it('should not call legacyAuthenticateRequest when middleware context exists', async () => { + const mockContext = { + get: vi.fn().mockImplementation(contextKey => { + if (contextKey === authFnContext) { + return vi.fn().mockImplementation((options?: any) => ({ + userId: 'user_xxx', + tokenType: TokenType.SessionToken, + ...options, + })); + } + return null; + }), + set: vi.fn(), + }; + + const args = { + context: mockContext, + request: new Request('http://clerk.com'), + } as LoaderFunctionArgs; + + const auth = await getAuth(args); + + expect(legacyAuthenticateRequest).not.toHaveBeenCalled(); + expect(auth.userId).toBe('user_xxx'); + expect(auth.tokenType).toBe('session_token'); + }); + + it('should call legacyAuthenticateRequest when middleware context is missing', async () => { + const mockContext = { + get: vi.fn().mockReturnValue(null), + }; + + const args = { + context: mockContext, + request: new Request('http://clerk.com'), + } as LoaderFunctionArgs; + + const auth = await getAuth(args); + + expect(legacyAuthenticateRequest).toHaveBeenCalled(); + expect(auth.userId).toBe('user_xxx'); + expect(auth.tokenType).toBe('session_token'); + }); +}); diff --git a/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts b/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts new file mode 100644 index 00000000000..e8bc86e116b --- /dev/null +++ b/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts @@ -0,0 +1,251 @@ +import { TokenType } from '@clerk/backend/internal'; +import { logger } from '@clerk/shared/logger'; +import { data, type LoaderFunctionArgs } from 'react-router'; +import type { MockInstance } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { middlewareMigrationWarning } from '../../utils/errors'; +import { authFnContext, requestStateContext } from '../clerkMiddleware'; +import { legacyAuthenticateRequest } from '../legacyAuthenticateRequest'; +import { rootAuthLoader } from '../rootAuthLoader'; + +vi.mock('../legacyAuthenticateRequest', () => { + return { + legacyAuthenticateRequest: vi.fn().mockResolvedValue({ + toAuth: vi.fn().mockImplementation(() => ({ + userId: 'user_xxx', + tokenType: TokenType.SessionToken, + })), + headers: new Headers({ + 'x-clerk-auth-status': 'signed-in', + 'x-clerk-auth-reason': 'auth-reason', + 'x-clerk-auth-message': 'auth-message', + }), + status: 'signed-in', + }), + }; +}); + +describe('rootAuthLoader', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.CLERK_SECRET_KEY = 'sk_test_...'; + }); + + describe('with middleware context', () => { + const mockContext = { + get: vi.fn().mockImplementation(contextKey => { + if (contextKey === requestStateContext) { + return { + toAuth: vi.fn().mockImplementation(() => ({ + userId: 'user_xxx', + tokenType: TokenType.SessionToken, + })), + headers: new Headers(), + status: 'signed-in', + }; + } + if (contextKey === authFnContext) { + return vi.fn().mockImplementation((options?: any) => ({ + userId: 'user_xxx', + tokenType: TokenType.SessionToken, + ...options, + })); + } + return null; + }), + set: vi.fn(), + }; + + const args = { + context: mockContext, + request: new Request('http://clerk.com'), + } as LoaderFunctionArgs; + + it('should not call legacyAuthenticateRequest when middleware context exists', async () => { + const warnOnceSpy = vi.spyOn(logger, 'warnOnce').mockImplementation(() => {}); + + await rootAuthLoader(args, () => ({ data: 'test' })); + + expect(legacyAuthenticateRequest).not.toHaveBeenCalled(); + expect(warnOnceSpy).not.toHaveBeenCalled(); + + warnOnceSpy.mockRestore(); + }); + + it('should handle no callback', async () => { + const result = await rootAuthLoader(args); + + expect(result).toHaveProperty('clerkState'); + expect(legacyAuthenticateRequest).not.toHaveBeenCalled(); + }); + + it('should handle callback returning a Response', async () => { + const mockResponse = new Response(JSON.stringify({ message: 'Hello' }), { + headers: { 'Content-Type': 'application/json' }, + }); + + const response = await rootAuthLoader(args, () => mockResponse); + + expect(response).toBeInstanceOf(Response); + const json = await response.json(); + expect(json).toHaveProperty('message', 'Hello'); + expect(json).toHaveProperty('clerkState'); + + // Headers will be set by middleware + expect(response.headers.get('x-clerk-auth-reason')).toBeNull(); + expect(response.headers.get('x-clerk-auth-status')).toBeNull(); + expect(response.headers.get('x-clerk-auth-message')).toBeNull(); + + expect(legacyAuthenticateRequest).not.toHaveBeenCalled(); + }); + + it('should handle callback returning data()', async () => { + const result = await rootAuthLoader(args, () => data({ message: 'Hello from data()' })); + + const response = result as unknown as Response; + + expect(response).toBeInstanceOf(Response); + const json = await response.json(); + expect(json).toHaveProperty('message', 'Hello from data()'); + expect(json).toHaveProperty('clerkState'); + + // Headers will be set by middleware + expect(response.headers.get('x-clerk-auth-reason')).toBeNull(); + expect(response.headers.get('x-clerk-auth-status')).toBeNull(); + expect(response.headers.get('x-clerk-auth-message')).toBeNull(); + + expect(legacyAuthenticateRequest).not.toHaveBeenCalled(); + }); + + it('should handle callback returning plain object', async () => { + const nonCriticalData = new Promise(res => setTimeout(() => res('non-critical'), 5000)); + const plainObject = { message: 'Hello from plain object', nonCriticalData }; + + const result = await rootAuthLoader(args, () => plainObject); + + expect(result).toHaveProperty('message', 'Hello from plain object'); + expect(result).toHaveProperty('nonCriticalData', nonCriticalData); + expect(result).toHaveProperty('clerkState'); + + expect(legacyAuthenticateRequest).not.toHaveBeenCalled(); + }); + + it('should handle callback returning null', async () => { + const result = await rootAuthLoader(args, () => null); + + expect(result).toHaveProperty('clerkState'); + expect(legacyAuthenticateRequest).not.toHaveBeenCalled(); + }); + }); + + describe('without middleware context', () => { + const mockContext = { + // No get/set methods - simulates v8_middleware flag not enabled + }; + + const args = { + context: mockContext, + request: new Request('http://clerk.com'), + } as LoaderFunctionArgs; + + let warnOnceSpy: MockInstance<(msg: string) => void>; + + beforeEach(() => { + warnOnceSpy = vi.spyOn(logger, 'warnOnce').mockImplementation(() => {}); + }); + + afterEach(() => { + warnOnceSpy.mockRestore(); + }); + + it('should call legacyAuthenticateRequest when middleware context is missing', async () => { + await rootAuthLoader(args, () => ({ data: 'test' })); + + expect(legacyAuthenticateRequest).toHaveBeenCalled(); + expect(warnOnceSpy).toHaveBeenCalledWith(middlewareMigrationWarning); + }); + + it('should handle no callback', async () => { + const result = await rootAuthLoader(args); + + const response = result as Response; + + expect(result).toBeInstanceOf(Response); + expect(await response.json()).toHaveProperty('clerkState'); + expect(legacyAuthenticateRequest).toHaveBeenCalled(); + + expect(response.headers.get('x-clerk-auth-reason')).toBe('auth-reason'); + expect(response.headers.get('x-clerk-auth-status')).toBe('signed-in'); + expect(response.headers.get('x-clerk-auth-message')).toBe('auth-message'); + }); + + it('should handle callback returning Response', async () => { + const mockResponse = new Response(JSON.stringify({ message: 'Hello' })); + + const response = await rootAuthLoader(args, () => mockResponse); + + expect(response).toBeInstanceOf(Response); + expect(await response.json()).toHaveProperty('clerkState'); + + expect(response.headers.get('x-clerk-auth-reason')).toBe('auth-reason'); + expect(response.headers.get('x-clerk-auth-status')).toBe('signed-in'); + expect(response.headers.get('x-clerk-auth-message')).toBe('auth-message'); + + expect(legacyAuthenticateRequest).toHaveBeenCalled(); + }); + + it('should handle callback returning data()', async () => { + const result = await rootAuthLoader(args, () => data({ message: 'Hello from data()' })); + + const response = result as unknown as Response; + + expect(response).toBeInstanceOf(Response); + const json = await response.json(); + expect(json).toHaveProperty('message', 'Hello from data()'); + expect(json).toHaveProperty('clerkState'); + + expect(response.headers.get('x-clerk-auth-reason')).toBe('auth-reason'); + expect(response.headers.get('x-clerk-auth-status')).toBe('signed-in'); + expect(response.headers.get('x-clerk-auth-message')).toBe('auth-message'); + + expect(legacyAuthenticateRequest).toHaveBeenCalled(); + }); + + it('should handle callback returning plain object', async () => { + const nonCriticalData = new Promise(res => setTimeout(() => res('non-critical'), 5000)); + const plainObject = { message: 'Hello from plain object', nonCriticalData }; + + const result = await rootAuthLoader(args, () => plainObject); + + const response = result as unknown as Response; + + expect(result).toBeInstanceOf(Response); + const json = await response.json(); + expect(json).toHaveProperty('message', 'Hello from plain object'); + expect(json).toHaveProperty('nonCriticalData', {}); // serialized to {} + expect(json).toHaveProperty('clerkState'); + + expect(response.headers.get('x-clerk-auth-reason')).toBe('auth-reason'); + expect(response.headers.get('x-clerk-auth-status')).toBe('signed-in'); + expect(response.headers.get('x-clerk-auth-message')).toBe('auth-message'); + + expect(legacyAuthenticateRequest).toHaveBeenCalled(); + }); + + it('should handle callback returning null', async () => { + const result = await rootAuthLoader(args, () => null); + + const response = result as unknown as Response; + + expect(result).toBeInstanceOf(Response); + expect(await response.json()).toHaveProperty('clerkState'); + + expect(response.headers.get('x-clerk-auth-reason')).toBe('auth-reason'); + expect(response.headers.get('x-clerk-auth-status')).toBe('signed-in'); + expect(response.headers.get('x-clerk-auth-message')).toBe('auth-message'); + + expect(legacyAuthenticateRequest).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/react-router/src/server/clerkClient.ts b/packages/react-router/src/server/clerkClient.ts new file mode 100644 index 00000000000..3c52229da3b --- /dev/null +++ b/packages/react-router/src/server/clerkClient.ts @@ -0,0 +1,21 @@ +import { createClerkClient } from '@clerk/backend'; + +import { type DataFunctionArgs, loadOptions } from './loadOptions'; + +export const clerkClient = (args: DataFunctionArgs) => { + const options = loadOptions(args); + + const { apiUrl, secretKey, jwtKey, proxyUrl, isSatellite, domain, publishableKey, machineSecretKey } = options; + + return createClerkClient({ + apiUrl, + secretKey, + jwtKey, + proxyUrl, + isSatellite, + domain, + publishableKey, + machineSecretKey, + userAgent: `${PACKAGE_NAME}@${PACKAGE_VERSION}`, + }); +}; diff --git a/packages/react-router/src/server/clerkMiddleware.ts b/packages/react-router/src/server/clerkMiddleware.ts new file mode 100644 index 00000000000..458fb7c7bf4 --- /dev/null +++ b/packages/react-router/src/server/clerkMiddleware.ts @@ -0,0 +1,77 @@ +import type { AuthObject } from '@clerk/backend'; +import type { RequestState } from '@clerk/backend/internal'; +import { AuthStatus, constants, createClerkRequest } from '@clerk/backend/internal'; +import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; +import type { PendingSessionOptions } from '@clerk/types'; +import type { MiddlewareFunction } from 'react-router'; +import { createContext } from 'react-router'; + +import { clerkClient } from './clerkClient'; +import { loadOptions } from './loadOptions'; +import type { ClerkMiddlewareOptions } from './types'; +import { patchRequest } from './utils'; + +export const authFnContext = createContext<((options?: PendingSessionOptions) => AuthObject) | null>(null); +export const requestStateContext = createContext | null>(null); + +/** + * Middleware that integrates Clerk authentication into your React Router application. + * It checks the request's cookies and headers for a session JWT and, if found, + * attaches the Auth object to a context. + * + * @example + * // react-router.config.ts + * export default { + * future: { + * v8_middleware: true, + * }, + * } + * + * // root.tsx + * export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()] + */ +export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFunction => { + return async (args, next) => { + const clerkRequest = createClerkRequest(patchRequest(args.request)); + const loadedOptions = loadOptions(args, options); + const { audience, authorizedParties } = loadedOptions; + const { signInUrl, signUpUrl, afterSignInUrl, afterSignUpUrl } = loadedOptions; + const requestState = await clerkClient(args).authenticateRequest(clerkRequest, { + audience, + authorizedParties, + signInUrl, + signUpUrl, + afterSignInUrl, + afterSignUpUrl, + acceptsToken: 'any', + }); + + const locationHeader = requestState.headers.get(constants.Headers.Location); + if (locationHeader) { + handleNetlifyCacheInDevInstance({ + locationHeader, + requestStateHeaders: requestState.headers, + publishableKey: requestState.publishableKey, + }); + // Trigger a handshake redirect + return new Response(null, { status: 307, headers: requestState.headers }); + } + + if (requestState.status === AuthStatus.Handshake) { + throw new Error('Clerk: handshake status without redirect'); + } + + args.context.set(authFnContext, (options?: PendingSessionOptions) => requestState.toAuth(options)); + args.context.set(requestStateContext, requestState); + + const response = await next(); + + if (requestState.headers) { + requestState.headers.forEach((value, key) => { + response.headers.append(key, value); + }); + } + + return response; + }; +}; diff --git a/packages/react-router/src/server/getAuth.ts b/packages/react-router/src/server/getAuth.ts new file mode 100644 index 00000000000..0c3acbb171a --- /dev/null +++ b/packages/react-router/src/server/getAuth.ts @@ -0,0 +1,46 @@ +import { + type AuthenticateRequestOptions, + type GetAuthFn, + getAuthObjectForAcceptedToken, +} from '@clerk/backend/internal'; +import type { PendingSessionOptions } from '@clerk/types'; +import type { LoaderFunctionArgs } from 'react-router'; + +import { IsOptIntoMiddleware } from '../server/utils'; +import { noLoaderArgsPassedInGetAuth } from '../utils/errors'; +import { authFnContext } from './clerkMiddleware'; +import { legacyAuthenticateRequest } from './legacyAuthenticateRequest'; +import { loadOptions } from './loadOptions'; + +type GetAuthOptions = PendingSessionOptions & { acceptsToken?: AuthenticateRequestOptions['acceptsToken'] }; + +export const getAuth: GetAuthFn = (async ( + args: LoaderFunctionArgs, + opts?: GetAuthOptions, +) => { + if (!args || (args && (!args.request || !args.context))) { + throw new Error(noLoaderArgsPassedInGetAuth); + } + + const { acceptsToken, treatPendingAsSignedOut, ...restOptions } = opts || {}; + + // If the middleware is installed, use the auth function from the context + const authObjectFn = IsOptIntoMiddleware(args.context) && args.context.get(authFnContext); + if (authObjectFn) { + return getAuthObjectForAcceptedToken({ + authObject: authObjectFn({ treatPendingAsSignedOut }), + acceptsToken, + }); + } + + // Fallback to the legacy authenticateRequest if the middleware is not installed + const loadedOptions = loadOptions(args, restOptions); + const requestState = await legacyAuthenticateRequest(args, { + ...loadedOptions, + acceptsToken: 'any', + }); + + const authObject = requestState.toAuth({ treatPendingAsSignedOut }); + + return getAuthObjectForAcceptedToken({ authObject, acceptsToken }); +}) as GetAuthFn; diff --git a/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts new file mode 100644 index 00000000000..fb78cd1fed7 --- /dev/null +++ b/packages/react-router/src/server/index.ts @@ -0,0 +1,5 @@ +export * from '@clerk/backend'; +export { clerkMiddleware } from './clerkMiddleware'; +export { rootAuthLoader } from './rootAuthLoader'; +export { getAuth } from './getAuth'; +export { clerkClient } from './clerkClient'; diff --git a/packages/react-router/src/ssr/authenticateRequest.ts b/packages/react-router/src/server/legacyAuthenticateRequest.ts similarity index 86% rename from packages/react-router/src/ssr/authenticateRequest.ts rename to packages/react-router/src/server/legacyAuthenticateRequest.ts index a0c434ca881..7b2d704cee8 100644 --- a/packages/react-router/src/ssr/authenticateRequest.ts +++ b/packages/react-router/src/server/legacyAuthenticateRequest.ts @@ -1,12 +1,12 @@ -import { createClerkClient } from '@clerk/backend'; import type { AuthenticateRequestOptions, SignedInState, SignedOutState } from '@clerk/backend/internal'; import { AuthStatus, constants } from '@clerk/backend/internal'; import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; import type { LoaderFunctionArgs } from 'react-router'; +import { clerkClient } from './clerkClient'; import { patchRequest } from './utils'; -export async function authenticateRequest( +export async function legacyAuthenticateRequest( args: LoaderFunctionArgs, opts: AuthenticateRequestOptions, ): Promise { @@ -16,7 +16,7 @@ export async function authenticateRequest( const { apiUrl, secretKey, jwtKey, proxyUrl, isSatellite, domain, publishableKey, machineSecretKey } = opts; const { signInUrl, signUpUrl, afterSignInUrl, afterSignUpUrl } = opts; - const requestState = await createClerkClient({ + const requestState = await clerkClient(args).authenticateRequest(patchRequest(request), { apiUrl, secretKey, jwtKey, @@ -25,8 +25,6 @@ export async function authenticateRequest( domain, publishableKey, machineSecretKey, - userAgent: `${PACKAGE_NAME}@${PACKAGE_VERSION}`, - }).authenticateRequest(patchRequest(request), { audience, authorizedParties, signInUrl, diff --git a/packages/react-router/src/ssr/loadOptions.ts b/packages/react-router/src/server/loadOptions.ts similarity index 90% rename from packages/react-router/src/ssr/loadOptions.ts rename to packages/react-router/src/server/loadOptions.ts index 969cce2dd12..6c64a7face8 100644 --- a/packages/react-router/src/ssr/loadOptions.ts +++ b/packages/react-router/src/server/loadOptions.ts @@ -4,14 +4,17 @@ import { getEnvVariable } from '@clerk/shared/getEnvVariable'; import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; import { isHttpOrHttps, isProxyUrlRelative } from '@clerk/shared/proxy'; import { handleValueOrFn } from '@clerk/shared/utils'; -import type { LoaderFunctionArgs } from 'react-router'; +import type { MiddlewareFunction } from 'react-router'; import { getPublicEnvVariables } from '../utils/env'; import { noSecretKeyError, satelliteAndMissingProxyUrlAndDomain, satelliteAndMissingSignInUrl } from '../utils/errors'; -import type { RootAuthLoaderOptions } from './types'; +import type { ClerkMiddlewareOptions } from './types'; import { patchRequest } from './utils'; -export const loadOptions = (args: LoaderFunctionArgs, overrides: RootAuthLoaderOptions = {}) => { +export type DataFunctionArgs = Parameters>[0]; + +export const loadOptions = (args: DataFunctionArgs, overrides: ClerkMiddlewareOptions = {}) => { + // see https://developers.cloudflare.com/workers/framework-guides/web-apps/react-router/#use-bindings-with-react-router const { request, context } = args; const clerkRequest = createClerkRequest(patchRequest(request)); diff --git a/packages/react-router/src/server/rootAuthLoader.ts b/packages/react-router/src/server/rootAuthLoader.ts new file mode 100644 index 00000000000..b3e108ca85b --- /dev/null +++ b/packages/react-router/src/server/rootAuthLoader.ts @@ -0,0 +1,172 @@ +import type { RequestState } from '@clerk/backend/internal'; +import { decorateObjectWithResources } from '@clerk/backend/internal'; +import { logger } from '@clerk/shared/logger'; +import type { LoaderFunctionArgs } from 'react-router'; + +import { invalidRootLoaderCallbackReturn, middlewareMigrationWarning } from '../utils/errors'; +import { authFnContext, requestStateContext } from './clerkMiddleware'; +import { legacyAuthenticateRequest } from './legacyAuthenticateRequest'; +import { loadOptions } from './loadOptions'; +import type { + LoaderFunctionArgsWithAuth, + LoaderFunctionReturn, + RootAuthLoaderCallback, + RootAuthLoaderOptions, +} from './types'; +import { + getResponseClerkState, + injectRequestStateIntoResponse, + isDataWithResponseInit, + IsOptIntoMiddleware, + isRedirect, + isResponse, +} from './utils'; + +interface RootAuthLoader { + >( + /** + * Arguments passed to the loader function. + */ + args: LoaderFunctionArgs, + /** + * A loader function with authentication state made available to it. Allows you to fetch route data based on the user's authentication state. + */ + callback: Callback, + options?: Options, + ): Promise>; + + (args: LoaderFunctionArgs, options?: RootAuthLoaderOptions): Promise; +} + +/** + * Shared logic for processing the root auth loader with a given request state + */ +async function processRootAuthLoader( + args: LoaderFunctionArgs, + requestState: RequestState, + handler?: RootAuthLoaderCallback, +): Promise { + const hasMiddleware = IsOptIntoMiddleware(args.context) && !!args.context.get(authFnContext); + const includeClerkHeaders = !hasMiddleware; + + if (!handler) { + // if the user did not provide a handler, simply inject requestState into an empty response + const { clerkState } = getResponseClerkState(requestState, args.context); + return { + ...clerkState, + }; + } + + // Create args that has the auth object in the request for backward compatibility + const argsWithAuth = { + ...args, + request: Object.assign(args.request, { auth: requestState.toAuth() }), + } as LoaderFunctionArgsWithAuth; + + const handlerResult = await handler(argsWithAuth); + + if (isResponse(handlerResult)) { + try { + // respect and pass-through any redirects without modifying them + if (isRedirect(handlerResult)) { + return handlerResult; + } + // clone and try to inject requestState into all json-like responses + // if this fails, the user probably didn't return a json object or a valid json string + return injectRequestStateIntoResponse(handlerResult, requestState, args.context, includeClerkHeaders); + } catch { + throw new Error(invalidRootLoaderCallbackReturn); + } + } + + if (isDataWithResponseInit(handlerResult)) { + try { + // clone and try to inject requestState into all json-like responses + // if this fails, the user probably didn't return a json object or a valid json string + return injectRequestStateIntoResponse( + new Response(JSON.stringify(handlerResult.data), handlerResult.init ?? undefined), + requestState, + args.context, + includeClerkHeaders, + ); + } catch { + throw new Error(invalidRootLoaderCallbackReturn); + } + } + + // If the return value of the user's handler is null or a plain object + if (includeClerkHeaders) { + // Legacy path: return Response with headers + const responseBody = JSON.stringify(handlerResult ?? {}); + return injectRequestStateIntoResponse(new Response(responseBody), requestState, args.context, includeClerkHeaders); + } + + // Middleware path: return plain object with streaming support + const { clerkState } = getResponseClerkState(requestState, args.context); + + return { + ...(handlerResult ?? {}), + ...clerkState, + }; +} + +/** + * Makes authorization state available in your application by wrapping the root loader. + * + * @see https://clerk.com/docs/references/react-router/root-auth-loader + */ +export const rootAuthLoader: RootAuthLoader = async ( + args: LoaderFunctionArgs, + handlerOrOptions: any, + options?: any, +): Promise => { + const handler = typeof handlerOrOptions === 'function' ? handlerOrOptions : undefined; + const opts: RootAuthLoaderOptions = options + ? options + : !!handlerOrOptions && typeof handlerOrOptions !== 'function' + ? handlerOrOptions + : {}; + + const hasMiddlewareFlag = IsOptIntoMiddleware(args.context); + const requestState = hasMiddlewareFlag && args.context.get(requestStateContext); + + if (!requestState) { + logger.warnOnce(middlewareMigrationWarning); + return legacyRootAuthLoader(args, handlerOrOptions, opts); + } + + return processRootAuthLoader(args, requestState, handler); +}; + +/** + * Legacy implementation that authenticates requests without middleware. + * This maintains backward compatibility for users who haven't migrated to the new middleware system. + */ +const legacyRootAuthLoader: RootAuthLoader = async ( + args: LoaderFunctionArgs, + handlerOrOptions: any, + options?: any, +): Promise => { + const handler = typeof handlerOrOptions === 'function' ? handlerOrOptions : undefined; + const opts: RootAuthLoaderOptions = options + ? options + : !!handlerOrOptions && typeof handlerOrOptions !== 'function' + ? handlerOrOptions + : {}; + + const loadedOptions = loadOptions(args, opts); + // Note: legacyAuthenticateRequest() will throw a redirect if the auth state is determined to be handshake + const _requestState = await legacyAuthenticateRequest(args, loadedOptions); + const requestState = { ...loadedOptions, ..._requestState }; + + if (!handler) { + // if the user did not provide a handler, simply inject requestState into an empty response + return injectRequestStateIntoResponse(new Response(JSON.stringify({})), requestState, args.context, true); + } + + const authObj = requestState.toAuth(); + const requestWithAuth = Object.assign(args.request, { auth: authObj }); + await decorateObjectWithResources(requestWithAuth, authObj, loadedOptions); + + return processRootAuthLoader(args, requestState, handler); +}; diff --git a/packages/react-router/src/ssr/types.ts b/packages/react-router/src/server/types.ts similarity index 96% rename from packages/react-router/src/ssr/types.ts rename to packages/react-router/src/server/types.ts index 87d54d5994a..9f8e0847496 100644 --- a/packages/react-router/src/ssr/types.ts +++ b/packages/react-router/src/server/types.ts @@ -12,7 +12,7 @@ import type { LoaderFunction, LoaderFunctionArgs, UNSAFE_DataWithResponseInit } export type GetAuthReturn = Promise; -export type RootAuthLoaderOptions = { +export type ClerkMiddlewareOptions = { /** * Used to override the default VITE_CLERK_PUBLISHABLE_KEY env variable if needed. */ @@ -29,6 +29,17 @@ export type RootAuthLoaderOptions = { * Used to override the CLERK_MACHINE_SECRET_KEY env variable if needed. */ machineSecretKey?: string; + signInUrl?: string; + signUpUrl?: string; +} & Pick & + MultiDomainAndOrProxy & + SignInForceRedirectUrl & + SignInFallbackRedirectUrl & + SignUpForceRedirectUrl & + SignUpFallbackRedirectUrl & + LegacyRedirectProps; + +export type RootAuthLoaderOptions = ClerkMiddlewareOptions & { /** * @deprecated Use [session token claims](https://clerk.com/docs/backend-requests/making/custom-session-token) instead. */ @@ -41,15 +52,7 @@ export type RootAuthLoaderOptions = { * @deprecated Use [session token claims](https://clerk.com/docs/backend-requests/making/custom-session-token) instead. */ loadOrganization?: boolean; - signInUrl?: string; - signUpUrl?: string; -} & Pick & - MultiDomainAndOrProxy & - SignInForceRedirectUrl & - SignInFallbackRedirectUrl & - SignUpForceRedirectUrl & - SignUpFallbackRedirectUrl & - LegacyRedirectProps; +}; export type RequestStateWithRedirectUrls = RequestState & SignInForceRedirectUrl & diff --git a/packages/react-router/src/ssr/utils.ts b/packages/react-router/src/server/utils.ts similarity index 89% rename from packages/react-router/src/ssr/utils.ts rename to packages/react-router/src/server/utils.ts index 376539c3bc9..6b5552da0d6 100644 --- a/packages/react-router/src/ssr/utils.ts +++ b/packages/react-router/src/server/utils.ts @@ -40,10 +40,19 @@ export function assertValidHandlerResult(val: any, error?: string): asserts val } } +/** + * `get` and `set` properties will only be available if v8_middleware flag is enabled + * See: https://reactrouter.com/upgrading/future#futurev8_middleware + */ +export const IsOptIntoMiddleware = (context: AppLoadContext) => { + return 'get' in context && 'set' in context; +}; + export const injectRequestStateIntoResponse = async ( response: Response, requestState: RequestStateWithRedirectUrls, context: AppLoadContext, + includeClerkHeaders = false, ) => { const clone = new Response(response.body, response); const data = await clone.json(); @@ -52,9 +61,13 @@ export const injectRequestStateIntoResponse = async ( // set the correct content-type header in case the user returned a `Response` directly clone.headers.set(constants.Headers.ContentType, constants.ContentTypes.Json); - headers.forEach((value, key) => { - clone.headers.append(key, value); - }); + + // Only add Clerk headers if requested (for legacy mode) + if (includeClerkHeaders) { + headers.forEach((value, key) => { + clone.headers.append(key, value); + }); + } return Response.json({ ...(data || {}), ...clerkState }, clone); }; diff --git a/packages/react-router/src/ssr/getAuth.ts b/packages/react-router/src/ssr/getAuth.ts deleted file mode 100644 index bc488619444..00000000000 --- a/packages/react-router/src/ssr/getAuth.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { - type AuthenticateRequestOptions, - type GetAuthFn, - getAuthObjectForAcceptedToken, -} from '@clerk/backend/internal'; -import type { LoaderFunctionArgs } from 'react-router'; - -import { noLoaderArgsPassedInGetAuth } from '../utils/errors'; -import { authenticateRequest } from './authenticateRequest'; -import { loadOptions } from './loadOptions'; -import type { RootAuthLoaderOptions } from './types'; - -type GetAuthOptions = { acceptsToken?: AuthenticateRequestOptions['acceptsToken'] } & Pick< - RootAuthLoaderOptions, - 'secretKey' ->; - -export const getAuth: GetAuthFn = (async ( - args: LoaderFunctionArgs, - opts?: GetAuthOptions, -) => { - if (!args || (args && (!args.request || !args.context))) { - throw new Error(noLoaderArgsPassedInGetAuth); - } - - const { acceptsToken, ...restOptions } = opts || {}; - - const loadedOptions = loadOptions(args, restOptions); - // Note: authenticateRequest() will throw a redirect if the auth state is determined to be handshake - const requestState = await authenticateRequest(args, { - ...loadedOptions, - acceptsToken: 'any', - }); - - const authObject = requestState.toAuth(); - - return getAuthObjectForAcceptedToken({ authObject, acceptsToken }); -}) as GetAuthFn; diff --git a/packages/react-router/src/ssr/index.ts b/packages/react-router/src/ssr/index.ts index fcd02aa9159..28e761a5b0f 100644 --- a/packages/react-router/src/ssr/index.ts +++ b/packages/react-router/src/ssr/index.ts @@ -1,5 +1,18 @@ -export * from './rootAuthLoader'; -export * from './getAuth'; +export { rootAuthLoader } from '../server/rootAuthLoader'; +export { getAuth } from '../server/getAuth'; +import { logger } from '@clerk/shared/logger'; + +logger.warnOnce(` +Clerk - DEPRECATION WARNING: \`@clerk/react-router/ssr.server\` has been deprecated and will be removed in the next major version. + +Import from \`@clerk/react-router/server\` instead. + +Before: + import { getAuth, clerkMiddleware, rootAuthLoader } from '@clerk/react-router/ssr.server'; + +After: + import { getAuth, clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server'; +`); /** * Re-export resource types from @clerk/backend diff --git a/packages/react-router/src/ssr/rootAuthLoader.ts b/packages/react-router/src/ssr/rootAuthLoader.ts deleted file mode 100644 index 688df50fbdf..00000000000 --- a/packages/react-router/src/ssr/rootAuthLoader.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { decorateObjectWithResources } from '@clerk/backend/internal'; -import type { LoaderFunctionArgs } from 'react-router'; - -import { invalidRootLoaderCallbackReturn } from '../utils/errors'; -import { authenticateRequest } from './authenticateRequest'; -import { loadOptions } from './loadOptions'; -import type { LoaderFunctionReturn, RootAuthLoaderCallback, RootAuthLoaderOptions } from './types'; -import { - assertValidHandlerResult, - injectRequestStateIntoResponse, - isDataWithResponseInit, - isRedirect, - isResponse, -} from './utils'; - -interface RootAuthLoader { - >( - /** - * Arguments passed to the loader function. - */ - args: LoaderFunctionArgs, - /** - * A loader function with authentication state made available to it. Allows you to fetch route data based on the user's authentication state. - */ - callback: Callback, - options?: Options, - ): Promise>; - - (args: LoaderFunctionArgs, options?: RootAuthLoaderOptions): Promise; -} - -/** - * Makes authorization state available in your application by wrapping the root loader. - * - * @see https://clerk.com/docs/quickstarts/react-router - */ -export const rootAuthLoader: RootAuthLoader = async ( - args: LoaderFunctionArgs, - handlerOrOptions: any, - options?: any, -): Promise => { - const handler = typeof handlerOrOptions === 'function' ? handlerOrOptions : undefined; - const opts: RootAuthLoaderOptions = options - ? options - : !!handlerOrOptions && typeof handlerOrOptions !== 'function' - ? handlerOrOptions - : {}; - - const loadedOptions = loadOptions(args, opts); - // Note: authenticateRequest() will throw a redirect if the auth state is determined to be handshake - const _requestState = await authenticateRequest(args, loadedOptions); - // TODO: Investigate if `authenticateRequest` needs to return the loadedOptions (the new request urls in particular) - const requestState = { ...loadedOptions, ..._requestState }; - - if (!handler) { - // if the user did not provide a handler, simply inject requestState into an empty response - return injectRequestStateIntoResponse(new Response(JSON.stringify({})), requestState, args.context); - } - - const authObj = requestState.toAuth(); - const requestWithAuth = Object.assign(args.request, { auth: authObj }); - await decorateObjectWithResources(requestWithAuth, authObj, loadedOptions); - const handlerResult = await handler(args); - assertValidHandlerResult(handlerResult, invalidRootLoaderCallbackReturn); - - if (isResponse(handlerResult)) { - try { - // respect and pass-through any redirects without modifying them - if (isRedirect(handlerResult)) { - return handlerResult; - } - // clone and try to inject requestState into all json-like responses - // if this fails, the user probably didn't return a json object or a valid json string - return injectRequestStateIntoResponse(handlerResult, requestState, args.context); - } catch { - throw new Error(invalidRootLoaderCallbackReturn); - } - } - - if (isDataWithResponseInit(handlerResult)) { - try { - // clone and try to inject requestState into all json-like responses - // if this fails, the user probably didn't return a json object or a valid json string - return injectRequestStateIntoResponse( - new Response(JSON.stringify(handlerResult.data), handlerResult.init ?? undefined), - requestState, - args.context, - ); - } catch { - throw new Error(invalidRootLoaderCallbackReturn); - } - } - - // if the return value of the user's handler is null or a plain object, create an empty response to inject Clerk's state into - const responseBody = JSON.stringify(handlerResult ?? {}); - - return injectRequestStateIntoResponse(new Response(responseBody), requestState, args.context); -}; diff --git a/packages/react-router/src/utils/errors.ts b/packages/react-router/src/utils/errors.ts index 8e0a7682f2b..be88c9880ae 100644 --- a/packages/react-router/src/utils/errors.ts +++ b/packages/react-router/src/utils/errors.ts @@ -94,3 +94,38 @@ Example: `); + +const middlewareMigrationExample = `To use the new middleware system, you need to: + +1. Enable the 'v8_middleware' future flag in your config: + +// react-router.config.ts +export default { + future: { + v8_middleware: true, + }, +} satisfies Config; + +2. Install the clerkMiddleware: + +import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server' +import { ClerkProvider } from '@clerk/react-router' + +export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()] + +export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args) + +export default function App({ loaderData }: Route.ComponentProps) { + return ( + + + + ) +} +`; + +export const middlewareMigrationWarning = createErrorMessage(` +'"clerkMiddleware()" not detected. + +${middlewareMigrationExample} +`);