diff --git a/.changeset/shiny-numbers-walk.md b/.changeset/shiny-numbers-walk.md new file mode 100644 index 0000000000..3ffc12abb8 --- /dev/null +++ b/.changeset/shiny-numbers-walk.md @@ -0,0 +1,6 @@ +--- +"@clerk/nextjs": major +--- + +Support `unstable_rethrow` inside `clerkMiddleware`. +We changed the errors thrown by `protect()` inside `clerkMiddleware` in order for `unstable_rethrow` to recognise them and rethrow them. diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index eee5510608..2db94271d5 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -23,12 +23,14 @@ export const createLongRunningApps = () => { { id: 'react.vite.withEmailLinks', config: react.vite, env: envs.withEmailLinks }, { id: 'remix.node.withEmailCodes', config: remix.remixNode, env: envs.withEmailCodes }, { id: 'next.appRouter.withEmailCodes', config: next.appRouter, env: envs.withEmailCodes }, + { id: 'next.appRouter.15RCwithEmailCodes', config: next.appRouter15Rc, env: envs.withEmailCodes }, { id: 'next.appRouter.withEmailCodes_persist_client', config: next.appRouter, env: envs.withEmailCodes_destroy_client, }, { id: 'next.appRouter.withCustomRoles', config: next.appRouter, env: envs.withCustomRoles }, + { id: 'next.appRouter.15RCwithCustomRoles', config: next.appRouter15Rc, env: envs.withCustomRoles }, { id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart }, { id: 'elements.next.appRouter', config: elements.nextAppRouter, env: envs.withEmailCodes }, { id: 'astro.node.withCustomRoles', config: astro.node, env: envs.withCustomRoles }, diff --git a/integration/presets/next.ts b/integration/presets/next.ts index 68366b7ffa..f0a66b5802 100644 --- a/integration/presets/next.ts +++ b/integration/presets/next.ts @@ -16,6 +16,60 @@ const appRouter = applicationConfig() .addDependency('react-dom', constants.E2E_REACT_DOM_VERSION) .addDependency('@clerk/nextjs', constants.E2E_CLERK_VERSION || clerkNextjsLocal); +const appRouter15Rc = applicationConfig() + .setName('next-app-router') + .useTemplate(templates['next-app-router']) + .setEnvFormatter('public', key => `NEXT_PUBLIC_${key}`) + .addScript('setup', constants.E2E_NPM_FORCE ? 'npm i --force' : 'npm i') + .addScript('dev', 'npm run dev') + .addScript('build', 'npm run build') + .addScript('serve', 'npm run start') + .addDependency('next', 'rc') + .addDependency('react', 'rc') + .addDependency('react-dom', 'rc') + .addDependency('@clerk/nextjs', constants.E2E_CLERK_VERSION || clerkNextjsLocal) + .addFile( + 'src/middleware.ts', + () => `import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; +import { unstable_rethrow } from 'next/navigation'; + +const csp = \`default-src 'self'; + script-src 'self' 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' 'nonce-deadbeef'; + img-src 'self' https://img.clerk.com; + worker-src 'self' blob:; + style-src 'self' 'unsafe-inline'; + frame-src 'self' https://challenges.cloudflare.com; +\`; + +const isProtectedRoute = createRouteMatcher(['/protected(.*)', '/user(.*)', '/switcher(.*)']); +const isAdminRoute = createRouteMatcher(['/only-admin(.*)']); +const isCSPRoute = createRouteMatcher(['/csp']); + +export default clerkMiddleware(async (auth, req) => { + if (isProtectedRoute(req)) { + try { await auth.protect() } + catch (e) { unstable_rethrow(e) } + } + + if (isAdminRoute(req)) { + try { await auth.protect({role: 'admin'}) } + catch (e) { unstable_rethrow(e) } + } + + if (isCSPRoute(req)) { + req.headers.set('Content-Security-Policy', csp.replace(/\\n/g, '')); + } +}); + +export const config = { + matcher: [ + '/((?!.*\\\\..*|_next).*)', // Don't run middleware on static files + '/', // Run middleware on index page + '/(api|trpc)(.*)', + ], // Run middleware on API routes +};`, + ); + const appRouterTurbo = appRouter .clone() .setName('next-app-router-turbopack') @@ -48,6 +102,7 @@ const appRouterAPWithClerkNextV4 = appRouterQuickstart export const next = { appRouter, + appRouter15Rc, appRouterTurbo, appRouterQuickstart, appRouterAPWithClerkNextLatest, diff --git a/integration/templates/next-app-router/src/app/only-admin/page.tsx b/integration/templates/next-app-router/src/app/only-admin/page.tsx new file mode 100644 index 0000000000..a2817c4a6d --- /dev/null +++ b/integration/templates/next-app-router/src/app/only-admin/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
User is admin
; +} diff --git a/integration/templates/next-app-router/src/middleware.ts b/integration/templates/next-app-router/src/middleware.ts index 71cc5ffa39..24c94de0e8 100644 --- a/integration/templates/next-app-router/src/middleware.ts +++ b/integration/templates/next-app-router/src/middleware.ts @@ -9,6 +9,7 @@ const csp = `default-src 'self'; `; const isProtectedRoute = createRouteMatcher(['/protected(.*)', '/user(.*)', '/switcher(.*)']); +const isAdminRoute = createRouteMatcher(['/only-admin(.*)']); const isCSPRoute = createRouteMatcher(['/csp']); export default clerkMiddleware(async (auth, req) => { @@ -16,6 +17,10 @@ export default clerkMiddleware(async (auth, req) => { await auth.protect(); } + if (isAdminRoute(req)) { + await auth.protect({ role: 'admin' }); + } + if (isCSPRoute(req)) { req.headers.set('Content-Security-Policy', csp.replace(/\n/g, '')); } diff --git a/integration/tests/protect.test.ts b/integration/tests/protect.test.ts index 7b2c6868d2..fc5a5bc143 100644 --- a/integration/tests/protect.test.ts +++ b/integration/tests/protect.test.ts @@ -56,6 +56,9 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('authoriz await u.page.goToRelative('/settings/auth-protect'); await expect(u.page.getByText(/User has access/i)).toBeVisible(); + await u.page.goToRelative('/only-admin'); + await expect(u.page.getByText(/User is admin/i)).toBeVisible(); + // route handler await u.page.goToRelative('/api/settings/'); await expect(u.page.getByText(/userId/i)).toBeVisible(); @@ -89,6 +92,8 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('authoriz await u.po.signIn.waitForMounted(); await u.page.goToRelative('/page-protected'); await u.po.signIn.waitForMounted(); + await u.page.goToRelative('/only-admin'); + await u.po.signIn.waitForMounted(); }); test('Protect in RSCs and RCCs as `viewer`', async ({ page, context }) => { @@ -114,6 +119,9 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('authoriz await u.page.goToRelative('/settings/auth-protect'); await expect(u.page.getByText(/this page could not be found/i)).toBeVisible(); + await u.page.goToRelative('/only-admin'); + await expect(u.page.getByText(/this page could not be found/i)).toBeVisible(); + // Route Handler await u.page.goToRelative('/api/settings/').catch(() => {}); diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index 7f8f60c437..318414d6cf 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -496,7 +496,7 @@ describe('clerkMiddleware(params)', () => { expect((await clerkClient()).authenticateRequest).toBeCalled(); }); - it('forwards headers from authenticateRequest when auth().protect() is called', async () => { + it('forwards headers from authenticateRequest when auth.protect() is called', async () => { const req = mockRequest({ url: '/protected', headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }), diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index a38ce924e9..52ace3c8d6 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -12,6 +12,14 @@ import { withLogger } from '../utils/debugLogger'; import { clerkClient } from './clerkClient'; import { PUBLISHABLE_KEY, SECRET_KEY, SIGN_IN_URL, SIGN_UP_URL } from './constants'; import { errorThrower } from './errorThrower'; +import { + isNextjsNotFoundError, + isNextjsRedirectError, + isRedirectToSignInError, + nextjsNotFound, + nextjsRedirectError, + redirectToSignInError, +} from './nextErrors'; import type { AuthProtect } from './protect'; import { createProtect } from './protect'; import type { NextMiddlewareEvtParam, NextMiddlewareRequestParam, NextMiddlewareReturn } from './types'; @@ -23,12 +31,6 @@ import { setRequestHeadersOnNextResponse, } from './utils'; -const CONTROL_FLOW_ERROR = { - FORCE_NOT_FOUND: 'CLERK_PROTECT_REWRITE', - REDIRECT_TO_URL: 'CLERK_PROTECT_REDIRECT_TO_URL', - REDIRECT_TO_SIGN_IN: 'CLERK_PROTECT_REDIRECT_TO_SIGN_IN', -}; - export type ClerkMiddlewareAuthObject = AuthObject & { redirectToSignIn: RedirectFun; }; @@ -58,16 +60,19 @@ interface ClerkMiddleware { * export default clerkMiddleware((auth, request, event) => { ... }, options); */ (handler: ClerkMiddlewareHandler, options?: ClerkMiddlewareOptions): NextMiddleware; + /** * @example * export default clerkMiddleware((auth, request, event) => { ... }, (req) => options); */ (handler: ClerkMiddlewareHandler, options?: ClerkMiddlewareOptionsCallback): NextMiddleware; + /** * @example * export default clerkMiddleware(options); */ (options?: ClerkMiddlewareOptions): NextMiddleware; + /** * @example * export default clerkMiddleware; @@ -228,9 +233,8 @@ const createMiddlewareRedirectToSignIn = ( clerkRequest: ClerkRequest, ): ClerkMiddlewareAuthObject['redirectToSignIn'] => { return (opts = {}) => { - const err = new Error(CONTROL_FLOW_ERROR.REDIRECT_TO_SIGN_IN) as any; - err.returnBackUrl = opts.returnBackUrl === null ? '' : opts.returnBackUrl || clerkRequest.clerkUrl.toString(); - throw err; + const url = clerkRequest.clerkUrl.toString(); + redirectToSignInError(url, opts.returnBackUrl); }; }; @@ -240,15 +244,12 @@ const createMiddlewareProtect = ( redirectToSignIn: RedirectFun, ) => { return (async (params: any, options: any) => { - const notFound = () => { - throw new Error(CONTROL_FLOW_ERROR.FORCE_NOT_FOUND) as any; - }; + const notFound = () => nextjsNotFound(); - const redirect = (url: string) => { - const err = new Error(CONTROL_FLOW_ERROR.REDIRECT_TO_URL) as any; - err.redirectUrl = url; - throw err; - }; + const redirect = (url: string) => + nextjsRedirectError(url, { + redirectUrl: url, + }); return createProtect({ request: clerkRequest, redirect, notFound, authObject, redirectToSignIn })(params, options); }) as unknown as Promise; @@ -262,25 +263,28 @@ const createMiddlewareProtect = ( // This function handles the known errors thrown by the APIs described above, // and returns the appropriate response. const handleControlFlowErrors = (e: any, clerkRequest: ClerkRequest, requestState: RequestState): Response => { - switch (e.message) { - case CONTROL_FLOW_ERROR.FORCE_NOT_FOUND: - // Rewrite to a bogus URL to force not found error - return setHeader( - NextResponse.rewrite(`${clerkRequest.clerkUrl.origin}/clerk_${Date.now()}`), - constants.Headers.AuthReason, - 'protect-rewrite', - ); - case CONTROL_FLOW_ERROR.REDIRECT_TO_URL: - return redirectAdapter(e.redirectUrl); - case CONTROL_FLOW_ERROR.REDIRECT_TO_SIGN_IN: - return createRedirect({ - redirectAdapter, - baseUrl: clerkRequest.clerkUrl, - signInUrl: requestState.signInUrl, - signUpUrl: requestState.signUpUrl, - publishableKey: requestState.publishableKey, - }).redirectToSignIn({ returnBackUrl: e.returnBackUrl }); - default: - throw e; + if (isNextjsNotFoundError(e)) { + // Rewrite to a bogus URL to force not found error + return setHeader( + NextResponse.rewrite(`${clerkRequest.clerkUrl.origin}/clerk_${Date.now()}`), + constants.Headers.AuthReason, + 'protect-rewrite', + ); + } + + if (isRedirectToSignInError(e)) { + return createRedirect({ + redirectAdapter, + baseUrl: clerkRequest.clerkUrl, + signInUrl: requestState.signInUrl, + signUpUrl: requestState.signUpUrl, + publishableKey: requestState.publishableKey, + }).redirectToSignIn({ returnBackUrl: e.returnBackUrl }); } + + if (isNextjsRedirectError(e)) { + return redirectAdapter(e.redirectUrl); + } + + throw e; }; diff --git a/packages/nextjs/src/server/nextErrors.ts b/packages/nextjs/src/server/nextErrors.ts new file mode 100644 index 0000000000..c0594fd3a3 --- /dev/null +++ b/packages/nextjs/src/server/nextErrors.ts @@ -0,0 +1,111 @@ +/** + * Clerk's identifiers that are used alongside the ones from Next.js + */ +const CONTROL_FLOW_ERROR = { + FORCE_NOT_FOUND: 'CLERK_PROTECT_REWRITE', + REDIRECT_TO_URL: 'CLERK_PROTECT_REDIRECT_TO_URL', + REDIRECT_TO_SIGN_IN: 'CLERK_PROTECT_REDIRECT_TO_SIGN_IN', +}; + +/** + * In-house implementation of `notFound()` + * https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/not-found.ts + */ +const NOT_FOUND_ERROR_CODE = 'NEXT_NOT_FOUND'; + +type NotFoundError = Error & { + digest: typeof NOT_FOUND_ERROR_CODE; + clerk_digest: typeof CONTROL_FLOW_ERROR.FORCE_NOT_FOUND; +}; + +function isNextjsNotFoundError(error: unknown): error is NotFoundError { + if (typeof error !== 'object' || error === null || !('digest' in error)) { + return false; + } + + return error.digest === NOT_FOUND_ERROR_CODE; +} + +function nextjsNotFound(): never { + const error = new Error(NOT_FOUND_ERROR_CODE); + (error as NotFoundError).digest = NOT_FOUND_ERROR_CODE; + (error as NotFoundError).clerk_digest = CONTROL_FLOW_ERROR.FORCE_NOT_FOUND; + throw error; +} + +/** + * In-house implementation of `redirect()` + * https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/redirect.ts + */ + +const REDIRECT_ERROR_CODE = 'NEXT_REDIRECT'; + +type RedirectError = Error & { + digest: `${typeof REDIRECT_ERROR_CODE};${'replace'};${string};${307};`; + clerk_digest: typeof CONTROL_FLOW_ERROR.REDIRECT_TO_URL | typeof CONTROL_FLOW_ERROR.REDIRECT_TO_SIGN_IN; +} & T; + +function nextjsRedirectError( + url: string, + extra: Record, + type: 'replace' = 'replace', + statusCode: 307 = 307, +): never { + const error = new Error(REDIRECT_ERROR_CODE) as RedirectError; + error.digest = `${REDIRECT_ERROR_CODE};${type};${url};${statusCode};`; + error.clerk_digest = CONTROL_FLOW_ERROR.REDIRECT_TO_URL; + Object.assign(error, extra); + throw error; +} + +function redirectToSignInError(url: string, returnBackUrl?: string | URL | null): never { + nextjsRedirectError(url, { + clerk_digest: CONTROL_FLOW_ERROR.REDIRECT_TO_SIGN_IN, + returnBackUrl: returnBackUrl === null ? '' : returnBackUrl || url, + }); +} + +/** + * Checks an error to determine if it's an error generated by the + * `redirect(url)` helper. + * + * @param error the error that may reference a redirect error + * @returns true if the error is a redirect error + */ +function isNextjsRedirectError(error: unknown): error is RedirectError<{ redirectUrl: string | URL }> { + if (typeof error !== 'object' || error === null || !('digest' in error) || typeof error.digest !== 'string') { + return false; + } + + const digest = error.digest.split(';'); + const [errorCode, type] = digest; + const destination = digest.slice(2, -2).join(';'); + const status = digest.at(-2); + + const statusCode = Number(status); + + return ( + errorCode === REDIRECT_ERROR_CODE && + (type === 'replace' || type === 'push') && + typeof destination === 'string' && + !isNaN(statusCode) && + statusCode === 307 + ); +} + +function isRedirectToSignInError(error: unknown): error is RedirectError<{ returnBackUrl: string | URL }> { + if (isNextjsRedirectError(error) && 'clerk_digest' in error) { + return error.clerk_digest === CONTROL_FLOW_ERROR.REDIRECT_TO_SIGN_IN; + } + + return false; +} + +export { + isNextjsNotFoundError, + nextjsNotFound, + redirectToSignInError, + nextjsRedirectError, + isNextjsRedirectError, + isRedirectToSignInError, +};