Skip to content

Commit

Permalink
chore(nextjs): Support unstable_rethrow inside clerkMiddleware (#4347)
Browse files Browse the repository at this point in the history
Co-authored-by: Jacek <[email protected]>
  • Loading branch information
panteliselef and jacekradko authored Oct 22, 2024
1 parent 1b32955 commit 7149cc2
Show file tree
Hide file tree
Showing 9 changed files with 232 additions and 38 deletions.
6 changes: 6 additions & 0 deletions .changeset/shiny-numbers-walk.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions integration/presets/longRunningApps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
55 changes: 55 additions & 0 deletions integration/presets/next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -48,6 +102,7 @@ const appRouterAPWithClerkNextV4 = appRouterQuickstart

export const next = {
appRouter,
appRouter15Rc,
appRouterTurbo,
appRouterQuickstart,
appRouterAPWithClerkNextLatest,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <div>User is admin</div>;
}
5 changes: 5 additions & 0 deletions integration/templates/next-app-router/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@ 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) => {
if (isProtectedRoute(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, ''));
}
Expand Down
8 changes: 8 additions & 0 deletions integration/tests/protect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 }) => {
Expand All @@ -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(() => {});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
Expand Down
78 changes: 41 additions & 37 deletions packages/nextjs/src/server/clerkMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<Response>;
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
};
};

Expand All @@ -240,15 +244,12 @@ const createMiddlewareProtect = (
redirectToSignIn: RedirectFun<Response>,
) => {
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<AuthProtect>;
Expand All @@ -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;
};
111 changes: 111 additions & 0 deletions packages/nextjs/src/server/nextErrors.ts
Original file line number Diff line number Diff line change
@@ -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<T = unknown> = 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<string, unknown>,
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,
};

0 comments on commit 7149cc2

Please sign in to comment.