diff --git a/.changeset/funny-avocados-shout.md b/.changeset/funny-avocados-shout.md new file mode 100644 index 00000000000..acb59d29f00 --- /dev/null +++ b/.changeset/funny-avocados-shout.md @@ -0,0 +1,64 @@ +--- +'@clerk/nextjs': patch +--- + +Introduce the new `clerkMiddleware` helper to allow for more flexibility in how Clerk is integrated into your Next.js middleware. Example usage can be found below, for more details, For more details, please see the [clerkMiddleware](https://clerk.com/docs/references/nextjs/clerkMiddleware) documentation. +The `clerkMiddleware` helper effectively replaces the older `authMiddleware` helper, which is now considered deprecated and will be removed in the next major release. + +### 1. Protect a route that requires authentication + +```js +import { clerkMiddleware } from '@clerk/nextjs/server'; + +export default clerkMiddleware(auth => { + const { userId } = auth().protect(); + // userId is now available for use in your route handler + // for page requests, calling protect will automatically redirect the user to the sign-in URL if they are not authenticated + return NextResponse.next(); +}); +``` + + +### 2. Protect a route that requires specific permissions + +```js +import { clerkMiddleware } from '@clerk/nextjs/server'; + +export default clerkMiddleware(auth => { + const { userId } = auth().protect({ permission: 'org:domains:delete'}); + // userId is now available for use in your route handler + // for page requests, calling protect will automatically throw a notFound error if the user does not have the required permissions + return NextResponse.next(); +}); +``` + +### 2. Manually redirect to sign-in URL using the redirectToSignIn helper + +```js +import { clerkMiddleware } from '@clerk/nextjs/server'; + +export default clerkMiddleware(auth => { + // If you want more fine-grained control, you can always use the low-level redirectToSignIn helper + if(!auth().userId) { + return auth().redirectToSignIn(); + } + + return NextResponse.next(); +}); +``` + +This commit also introduces the experimental `createRouteMatcher` helper, which can be used to create a route matcher that matches a route against the current request. This is useful for creating custom logic based on which routes you want to handle as protected or public. + +```js +import { clerkMiddleware, experimental_createRouteMatcher } from '@clerk/nextjs/server'; + +const isProtectedRoute = experimental_createRouteMatcher([/protected.*/]); + +export default clerkMiddleware((auth, request) => { + if(isProtectedRoute(request)) { + auth().protect(); + } + + return NextResponse.next(); +}); +``` diff --git a/integration/constants.ts b/integration/constants.ts index 7b559d5ae6d..6a931413e04 100644 --- a/integration/constants.ts +++ b/integration/constants.ts @@ -38,6 +38,7 @@ export const constants = { */ E2E_APP_SK: process.env.E2E_APP_SK, E2E_APP_PK: process.env.E2E_APP_PK, + E2E_CLERK_API_URL: process.env.E2E_CLERK_API_URL, /** * The version of the dependency to use, controlled programmatically. */ diff --git a/integration/models/longRunningApplication.ts b/integration/models/longRunningApplication.ts index f21d755804c..8eef34c67f4 100644 --- a/integration/models/longRunningApplication.ts +++ b/integration/models/longRunningApplication.ts @@ -43,7 +43,7 @@ export const longRunningApplication = (params: LongRunningApplicationParams) => if (!stateFile.getLongRunningApps() || [port, serverUrl, pid, appDir, env].filter(Boolean).length === 0) { return; } - const data = stateFile.getLongRunningApps()[id]; + const data = stateFile.getLongRunningApps()[id] || {}; port ||= data.port; serverUrl ||= data.serverUrl; pid ||= data.pid; diff --git a/integration/scripts/setup.ts b/integration/scripts/setup.ts index 25f81550c28..f2a729b2a11 100644 --- a/integration/scripts/setup.ts +++ b/integration/scripts/setup.ts @@ -1,4 +1,3 @@ -/* eslint-disable turbo/no-undeclared-env-vars */ import { constants } from '../constants'; export const parseEnvOptions = () => { @@ -6,10 +5,11 @@ export const parseEnvOptions = () => { const appUrl = constants.E2E_APP_URL; const appPk = constants.E2E_APP_PK; const appSk = constants.E2E_APP_SK; + const clerkApiUrl = constants.E2E_CLERK_API_URL; if (appIds.length && appUrl) { throw new Error('E2E_APP_ID cannot be used with E2E_APP_URL'); } - return { appIds, appUrl, appPk, appSk }; + return { appIds, appUrl, appPk, appSk, clerkApiUrl }; }; diff --git a/integration/testUtils/testAgainstRunningApps.ts b/integration/testUtils/testAgainstRunningApps.ts index 837ea38aa7e..9638be24876 100644 --- a/integration/testUtils/testAgainstRunningApps.ts +++ b/integration/testUtils/testAgainstRunningApps.ts @@ -20,7 +20,7 @@ type RunningAppsParams = { */ const runningApps = (params: RunningAppsParams = {}) => { const withEnv = [params.withEnv].flat().filter(Boolean); - const { appIds, appUrl, appPk, appSk } = parseEnvOptions(); + const { appIds, appUrl, appPk, appSk, clerkApiUrl } = parseEnvOptions(); if (appIds.length) { // if appIds are provided, we only return the apps with the given ids const filter = app => (withEnv.length ? withEnv.includes(app.env) : true); @@ -31,6 +31,7 @@ const runningApps = (params: RunningAppsParams = {}) => { const env = environmentConfig() .setId('tempEnv') .setEnvVariable('private', 'CLERK_SECRET_KEY', appSk) + .setEnvVariable('private', 'CLERK_API_URL', clerkApiUrl) .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', appPk); return [longRunningApplication({ id: 'standalone', env, serverUrl: appUrl, config: applicationConfig() })]; }; diff --git a/integration/tests/protect.test.ts b/integration/tests/protect.test.ts index 506d722a362..88f422a890d 100644 --- a/integration/tests/protect.test.ts +++ b/integration/tests/protect.test.ts @@ -77,7 +77,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('authoriz await u.page.goToRelative('/settings/auth-has'); await expect(u.page.getByText(/User is missing permissions/i)).toBeVisible(); await u.page.goToRelative('/settings/auth-protect'); - await expect(u.page.getByText(/this page could not be found/i)).toBeVisible(); + await u.po.signIn.waitForMounted(); }); test('Protect in RSCs and RCCs as `viewer`', async ({ page, context }) => { diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index 47b9d7506a5..255b4b26a2f 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -42,6 +42,7 @@ const Headers = { Host: 'host', ContentType: 'content-type', SecFetchDest: 'sec-fetch-dest', + Location: 'location', } as const; const ContentTypes = { @@ -58,3 +59,5 @@ export const constants = { ContentTypes, QueryParameters, } as const; + +export type Constants = typeof constants; diff --git a/packages/backend/src/tokens/clerkRequest.ts b/packages/backend/src/tokens/clerkRequest.ts index 41fe49d2d6d..ef01f703440 100644 --- a/packages/backend/src/tokens/clerkRequest.ts +++ b/packages/backend/src/tokens/clerkRequest.ts @@ -1,25 +1,14 @@ import { parse as parseCookies } from 'cookie'; import { constants } from '../constants'; - -export type WithClerkUrl = T & { - /** - * When a NextJs app is hosted on a platform different from Vercel - * or inside a container (Netlify, Fly.io, AWS Amplify, docker etc), - * req.url is always set to `localhost:3000` instead of the actual host of the app. - * - * The `authMiddleware` uses the value of the available req.headers in order to construct - * and use the correct url internally. This url is then exposed as `experimental_clerkUrl`, - * intended to be used within `beforeAuth` and `afterAuth` if needed. - */ - clerkUrl: URL; -}; +import type { ClerkUrl, WithClerkUrl } from './clerkUrl'; +import { createClerkUrl } from './clerkUrl'; class ClerkRequest extends Request { - readonly clerkUrl: URL; + readonly clerkUrl: ClerkUrl; readonly cookies: Map; - public constructor(req: Request) { + public constructor(req: ClerkRequest | Request) { super(req, req); this.clerkUrl = this.deriveUrlFromHeaders(req); this.cookies = this.parseCookies(req); @@ -44,7 +33,7 @@ class ClerkRequest extends Request { const resolvedProtocol = this.getFirstValueFromHeader(forwardedProto) ?? protocol?.replace(/[:/]/, ''); const origin = resolvedHost && resolvedProtocol ? `${resolvedProtocol}://${resolvedHost}` : initialUrl.origin; - return new URL(initialUrl.pathname + initialUrl.search, origin); + return createClerkUrl(initialUrl.pathname + initialUrl.search, origin); } private getFirstValueFromHeader(value?: string | null) { @@ -62,7 +51,7 @@ class ClerkRequest extends Request { } export const createClerkRequest = (...args: ConstructorParameters): ClerkRequest => { - return new ClerkRequest(...args); + return args[0] instanceof ClerkRequest ? args[0] : new ClerkRequest(...args); }; export type { ClerkRequest }; diff --git a/packages/backend/src/tokens/clerkUrl.ts b/packages/backend/src/tokens/clerkUrl.ts new file mode 100644 index 00000000000..e6229d93b36 --- /dev/null +++ b/packages/backend/src/tokens/clerkUrl.ts @@ -0,0 +1,24 @@ +class ClerkUrl extends URL { + public isCrossOrigin(other: URL | string) { + return this.origin !== new URL(other.toString()).origin; + } +} + +export type WithClerkUrl = T & { + /** + * When a NextJs app is hosted on a platform different from Vercel + * or inside a container (Netlify, Fly.io, AWS Amplify, docker etc), + * req.url is always set to `localhost:3000` instead of the actual host of the app. + * + * The `authMiddleware` uses the value of the available req.headers in order to construct + * and use the correct url internally. This url is then exposed as `experimental_clerkUrl`, + * intended to be used within `beforeAuth` and `afterAuth` if needed. + */ + clerkUrl: ClerkUrl; +}; + +export const createClerkUrl = (...args: ConstructorParameters): ClerkUrl => { + return new ClerkUrl(...args); +}; + +export type { ClerkUrl }; diff --git a/packages/fastify/src/__snapshots__/constants.test.ts.snap b/packages/fastify/src/__snapshots__/constants.test.ts.snap index 99998a7f10f..7392388f438 100644 --- a/packages/fastify/src/__snapshots__/constants.test.ts.snap +++ b/packages/fastify/src/__snapshots__/constants.test.ts.snap @@ -24,6 +24,7 @@ exports[`constants from environment variables 1`] = ` "ForwardedPort": "x-forwarded-port", "ForwardedProto": "x-forwarded-proto", "Host": "host", + "Location": "location", "Origin": "origin", "Referrer": "referer", "SecFetchDest": "sec-fetch-dest", diff --git a/packages/nextjs/src/app-router/server/ClerkProvider.tsx b/packages/nextjs/src/app-router/server/ClerkProvider.tsx index f5369f02d45..a3e10f0bbc0 100644 --- a/packages/nextjs/src/app-router/server/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/server/ClerkProvider.tsx @@ -1,14 +1,12 @@ -import type { ClerkProviderOptionsWrapper } from '@clerk/clerk-react'; -import type { InitialState } from '@clerk/types'; +import type { InitialState, Without } from '@clerk/types'; import React from 'react'; +import type { NextClerkProviderProps } from '../../types'; import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv'; import { ClientClerkProvider } from '../client/ClerkProvider'; import { initialState } from './auth'; -type NextAppClerkProviderProps = ClerkProviderOptionsWrapper; - -export function ClerkProvider(props: NextAppClerkProviderProps) { +export function ClerkProvider(props: Without) { const { children, ...rest } = props; const state = initialState()?.__clerk_ssr_state as InitialState; diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts index 2754eef7dc3..3363bf1df70 100644 --- a/packages/nextjs/src/app-router/server/auth.ts +++ b/packages/nextjs/src/app-router/server/auth.ts @@ -1,18 +1,20 @@ import type { AuthObject } from '@clerk/backend/internal'; +import { notFound, redirect } from 'next/navigation'; import { authAuthHeaderMissing } from '../../server/errors'; import { buildClerkProps, createGetAuth } from '../../server/getAuth'; -import type { AuthProtect } from './protect'; -import { createProtect } from './protect'; +import type { AuthProtect } from '../../server/protect'; +import { createProtect } from '../../server/protect'; import { buildRequestLike } from './utils'; export const auth = (): AuthObject & { protect: AuthProtect } => { + const request = buildRequestLike(); const authObject = createGetAuth({ debugLoggerName: 'auth()', noAuthStatusMessage: authAuthHeaderMissing(), - })(buildRequestLike()); + })(request); - return Object.assign(authObject, { protect: createProtect(authObject) }); + return Object.assign(authObject, { protect: createProtect({ request, authObject, notFound, redirect }) }); }; export const initialState = () => { diff --git a/packages/nextjs/src/app-router/server/protect.ts b/packages/nextjs/src/app-router/server/protect.ts deleted file mode 100644 index 995a9b088ce..00000000000 --- a/packages/nextjs/src/app-router/server/protect.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { AuthObject, SignedInAuthObject } from '@clerk/backend/internal'; -import type { - CheckAuthorizationParamsWithCustomPermissions, - CheckAuthorizationWithCustomPermissions, -} from '@clerk/types'; -import { notFound, redirect } from 'next/navigation'; - -type AuthProtectOptions = { redirectUrl?: string }; - -/** - * @experimental - * This function is experimental as it throws a Nextjs notFound error if user is not authenticated or authorized. - * In the future we would investigate a way to throw a more appropriate error that clearly describes the not authorized of authenticated status. - */ -export interface AuthProtect { - (params?: CheckAuthorizationParamsWithCustomPermissions, options?: AuthProtectOptions): SignedInAuthObject; - ( - params?: (has: CheckAuthorizationWithCustomPermissions) => boolean, - options?: AuthProtectOptions, - ): SignedInAuthObject; - (options?: AuthProtectOptions): SignedInAuthObject; -} - -export const createProtect = (authObj: AuthObject): AuthProtect => { - return (...args: any[]) => { - const paramsOrFunction = args[0]?.redirectUrl - ? undefined - : (args[0] as - | CheckAuthorizationParamsWithCustomPermissions - | ((has: CheckAuthorizationWithCustomPermissions) => boolean)); - const redirectUrl = (args[0]?.redirectUrl || args[1]?.redirectUrl) as string | undefined; - - const handleUnauthorized = (): never => { - if (redirectUrl) { - redirect(redirectUrl); - } - notFound(); - }; - - /** - * User is not authenticated - */ - if (!authObj.userId) { - return handleUnauthorized(); - } - - /** - * User is authenticated - */ - if (!paramsOrFunction) { - return authObj; - } - - /** - * if a function is passed and returns false then throw not found - */ - if (typeof paramsOrFunction === 'function') { - if (paramsOrFunction(authObj.has)) { - return authObj; - } - return handleUnauthorized(); - } - - /** - * Checking if user is authorized when permission or role is passed - */ - if (authObj.has(paramsOrFunction)) { - return authObj; - } - - return handleUnauthorized(); - }; -}; diff --git a/packages/nextjs/src/constants.ts b/packages/nextjs/src/constants.ts index 2e826912492..efea8daf3e0 100644 --- a/packages/nextjs/src/constants.ts +++ b/packages/nextjs/src/constants.ts @@ -2,6 +2,8 @@ const Headers = { NextRewrite: 'x-middleware-rewrite', NextResume: 'x-middleware-next', NextRedirect: 'Location', + NextUrl: 'next-url', + NextAction: 'next-action', } as const; export const constants = { diff --git a/packages/nextjs/src/pages/__tests__/index.test.tsx b/packages/nextjs/src/pages/__tests__/index.test.tsx index 8e76f7abc17..202ed9acbaa 100644 --- a/packages/nextjs/src/pages/__tests__/index.test.tsx +++ b/packages/nextjs/src/pages/__tests__/index.test.tsx @@ -31,27 +31,6 @@ describe('ClerkProvider', () => { it('domain + isSatellite (satellite app)', () => { expectTypeOf({ ...defaultProps, domain: 'test', isSatellite: true }).toMatchTypeOf(); }); - - it('only domain is not allowed', () => { - expectTypeOf({ ...defaultProps, domain: 'test' }).not.toMatchTypeOf(); - }); - - it('only isSatellite is not allowed', () => { - expectTypeOf({ ...defaultProps, isSatellite: true }).not.toMatchTypeOf(); - }); - - it('proxyUrl + domain is not allowed', () => { - expectTypeOf({ ...defaultProps, proxyUrl: 'test', domain: 'test' }).not.toMatchTypeOf(); - }); - - it('proxyUrl + domain + isSatellite is not allowed', () => { - expectTypeOf({ - ...defaultProps, - proxyUrl: 'test', - domain: 'test', - isSatellite: true, - }).not.toMatchTypeOf(); - }); }); describe('clerkJSVariant', () => { diff --git a/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap b/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap index 17c471ccec7..db01c5c1eb8 100644 --- a/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap @@ -6,8 +6,10 @@ exports[`/server public exports should not include a breaking change 1`] = ` "authMiddleware", "buildClerkProps", "clerkClient", + "clerkMiddleware", "createClerkClient", "currentUser", + "experimental_createRouteMatcher", "getAuth", "redirectToSignIn", "redirectToSignUp", diff --git a/packages/nextjs/src/server/authMiddleware.test.ts b/packages/nextjs/src/server/authMiddleware.test.ts index e0c06285d9b..0bb128315de 100644 --- a/packages/nextjs/src/server/authMiddleware.test.ts +++ b/packages/nextjs/src/server/authMiddleware.test.ts @@ -33,9 +33,10 @@ jest.mock('./redirect', () => { }); import { paths, setHeader } from '../utils'; -import { authMiddleware, createRouteMatcher, DEFAULT_CONFIG_MATCHER, DEFAULT_IGNORED_ROUTES } from './authMiddleware'; +import { authMiddleware, DEFAULT_CONFIG_MATCHER, DEFAULT_IGNORED_ROUTES } from './authMiddleware'; // used to assert the mock import { clerkClient } from './clerkClient'; +import { createRouteMatcher } from './routeMatcher'; /** * Disable console warnings about config matchers diff --git a/packages/nextjs/src/server/authMiddleware.ts b/packages/nextjs/src/server/authMiddleware.ts index d0803606b66..a31797d6ea9 100644 --- a/packages/nextjs/src/server/authMiddleware.ts +++ b/packages/nextjs/src/server/authMiddleware.ts @@ -1,39 +1,27 @@ import type { AuthenticateRequestOptions, AuthObject, ClerkRequest } from '@clerk/backend/internal'; import { AuthStatus, constants, createClerkRequest } from '@clerk/backend/internal'; -import { DEV_BROWSER_JWT_KEY, setDevBrowserJWTInURL } from '@clerk/shared/devBrowser'; import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; import { eventMethodCalled } from '@clerk/shared/telemetry'; -import type { Autocomplete } from '@clerk/types'; -import type Link from 'next/link'; import type { NextFetchEvent, NextMiddleware, NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; -import { isRedirect, mergeResponses, paths, setHeader, stringifyHeaders } from '../utils'; +import { isRedirect, mergeResponses, serverRedirectWithAuth, setHeader, stringifyHeaders } from '../utils'; import { withLogger } from '../utils/debugLogger'; import { clerkClient } from './clerkClient'; -import { PUBLISHABLE_KEY, SECRET_KEY } from './constants'; +import { createAuthenticateRequestOptions } from './clerkMiddleware'; +import { SECRET_KEY } from './constants'; import { informAboutProtectedRouteInfo, receivedRequestForIgnoredRoute } from './errors'; import { redirectToSignIn } from './redirect'; -import type { NextMiddlewareResult } from './types'; +import type { RouteMatcherParam } from './routeMatcher'; +import { createRouteMatcher } from './routeMatcher'; +import type { NextMiddlewareReturn } from './types'; import { apiEndpointUnauthorizedNextResponse, decorateRequest, decorateResponseWithObservabilityHeaders, - handleMultiDomainAndProxy, - isCrossOrigin, setRequestHeadersOnNextResponse, } from './utils'; -type WithPathPatternWildcard = `${T & string}(.*)`; -type NextTypedRoute['0']['href']> = T extends string ? T : never; - -// For extra safety, we won't recommend using a `/(.*)` route matcher. -type ExcludeRootPath = T extends '/' ? never : T; - -type RouteMatcherWithNextTypedRoutes = Autocomplete< - WithPathPatternWildcard> | NextTypedRoute ->; - /** * The default ideal matcher that excludes the _next directory (internals) and all static files, * but it will match the root route (/) and any routes that start with /api or /trpc. @@ -50,16 +38,10 @@ export const DEFAULT_IGNORED_ROUTES = [`/((?!api|trpc))(_next.*|.+\\.[\\w]+$)`]; */ export const DEFAULT_API_ROUTES = ['/api/(.*)', '/trpc/(.*)']; -type RouteMatcherParam = - | Array - | RegExp - | RouteMatcherWithNextTypedRoutes - | ((req: NextRequest) => boolean); - type IgnoredRoutesParam = Array | RegExp | string | ((req: NextRequest) => boolean); type ApiRoutesParam = IgnoredRoutesParam; -type WithClerkUrl = T & { +type WithExperimentalClerkUrl = T & { /** * When a NextJs app is hosted on a platform different from Vercel * or inside a container (Netlify, Fly.io, AWS Amplify, docker etc), @@ -73,15 +55,15 @@ type WithClerkUrl = T & { }; type BeforeAuthHandler = ( - req: WithClerkUrl, + req: WithExperimentalClerkUrl, evt: NextFetchEvent, -) => NextMiddlewareResult | Promise | false | Promise; +) => NextMiddlewareReturn | false | Promise; type AfterAuthHandler = ( auth: AuthObject & { isPublicRoute: boolean; isApiRoute: boolean }, - req: WithClerkUrl, + req: WithExperimentalClerkUrl, evt: NextFetchEvent, -) => NextMiddlewareResult | Promise; +) => NextMiddlewareReturn; type AuthMiddlewareParams = AuthenticateRequestOptions & { /** @@ -132,7 +114,7 @@ export interface AuthMiddleware { } /** - * @deprecated Use `clerkMiddleware` instead. + * @deprecated Use {@link clerkMiddleware}` instead. * Migration guide: https://clerk.com/docs/upgrade-guides/v5-introduction */ const authMiddleware: AuthMiddleware = (...args: unknown[]) => { @@ -167,6 +149,7 @@ const authMiddleware: AuthMiddleware = (...args: unknown[]) => { nextUrl: nextRequest.nextUrl.href, clerkUrl: nextRequest.experimental_clerkUrl.href, }); + logger.debug('Options debug', { ...options, beforeAuth: !!beforeAuth, afterAuth: !!afterAuth }); if (isIgnoredRoute(nextRequest)) { @@ -192,14 +175,10 @@ const authMiddleware: AuthMiddleware = (...args: unknown[]) => { return setHeader(beforeAuthRes, constants.Headers.AuthReason, 'redirect'); } - // TODO: fix type discrepancy between WithAuthOptions and AuthenticateRequestOptions - const authenticateRequestOptions = { - ...options, - secretKey: options.secretKey || SECRET_KEY, - publishableKey: options.publishableKey || PUBLISHABLE_KEY, - ...handleMultiDomainAndProxy(clerkRequest, options as AuthenticateRequestOptions), - } as AuthenticateRequestOptions; - const requestState = await clerkClient.authenticateRequest(clerkRequest, authenticateRequestOptions); + const requestState = await clerkClient.authenticateRequest( + clerkRequest, + createAuthenticateRequestOptions(clerkRequest, options), + ); const locationHeader = requestState.headers.get('location'); if (locationHeader) { @@ -218,6 +197,7 @@ const authMiddleware: AuthMiddleware = (...args: unknown[]) => { isPublicRoute: isPublicRoute(nextRequest), isApiRoute: isApiRoute(nextRequest), }); + logger.debug(() => ({ auth: JSON.stringify(auth), debug: auth.debug() })); const afterAuthRes = await (afterAuth || defaultAfterAuth)(auth, nextRequest, evt); const finalRes = mergeResponses(beforeAuthRes, afterAuthRes) || NextResponse.next(); @@ -226,7 +206,7 @@ const authMiddleware: AuthMiddleware = (...args: unknown[]) => { if (isRedirect(finalRes)) { logger.debug('Final response is redirect, following redirect'); const res = setHeader(finalRes, constants.Headers.AuthReason, 'redirect'); - return appendDevBrowserOnCrossOrigin(nextRequest, res, options); + return serverRedirectWithAuth(clerkRequest, res, options); } if (options.debug) { @@ -248,27 +228,12 @@ const authMiddleware: AuthMiddleware = (...args: unknown[]) => { export { authMiddleware }; -/** - * Create a function that matches a request against the specified routes. - * Precomputes the glob matchers for the public routes, so we don't have to - * recompile the regular expressions on every request. - */ -export const createRouteMatcher = (routes: RouteMatcherParam) => { - if (typeof routes === 'function') { - return (req: NextRequest) => routes(req); - } - - const routePatterns = [routes || ''].flat().filter(Boolean); - const matchers = precomputePathRegex(routePatterns); - return (req: NextRequest) => matchers.some(matcher => matcher.test(req.nextUrl.pathname)); -}; - const createDefaultAfterAuth = ( isPublicRoute: ReturnType, isApiRoute: ReturnType, params: AuthMiddlewareParams, ) => { - return (auth: AuthObject, req: WithClerkUrl) => { + return (auth: AuthObject, req: WithExperimentalClerkUrl) => { if (!auth.userId && !isPublicRoute(req)) { if (isApiRoute(req)) { informAboutProtectedRoute(req.experimental_clerkUrl.pathname, params, true); @@ -282,10 +247,6 @@ const createDefaultAfterAuth = ( }; }; -const precomputePathRegex = (patterns: Array) => { - return patterns.map(pattern => (pattern instanceof RegExp ? pattern : paths.toRegexp(pattern))); -}; - const matchRoutesStartingWith = (path: string) => { path = path.replace(/\/$/, ''); return new RegExp(`^${path}(/.*)?$`); @@ -312,31 +273,6 @@ const withDefaultPublicRoutes = (publicRoutes: RouteMatcherParam | undefined) => return routes; }; -// Grabs the dev browser JWT from cookies and appends it to the redirect URL when redirecting to cross-origin. -// Middleware runs on the server side, before clerk-js is loaded, that's why we need Cookies. -const appendDevBrowserOnCrossOrigin = (req: WithClerkUrl, res: Response, opts: AuthMiddlewareParams) => { - const location = res.headers.get('location'); - - const shouldAppendDevBrowser = res.headers.get(constants.Headers.ClerkRedirectTo) === 'true'; - - if ( - shouldAppendDevBrowser && - !!location && - isDevelopmentFromSecretKey(opts.secretKey || SECRET_KEY) && - isCrossOrigin(req.experimental_clerkUrl, location) - ) { - const dbJwt = req.cookies.get(DEV_BROWSER_JWT_KEY)?.value || ''; - - // Next.js 12.1+ allows redirects only to absolute URLs - const url = new URL(location); - - const urlWithDevBrowser = setDevBrowserJWTInURL(url, dbJwt); - - return NextResponse.redirect(urlWithDevBrowser.href, res); - } - return res; -}; - // - Default behavior: // If the route path is `['/api/(.*)*', '*/trpc/(.*)']` // or Request has `Content-Type: application/json` @@ -345,12 +281,14 @@ const appendDevBrowserOnCrossOrigin = (req: WithClerkUrl, res: Resp // // - If the user has provided a specific `apiRoutes` prop in `authMiddleware` then all the above are discarded, // and only routes that match the user’s provided paths are considered API routes. -const createApiRoutes = (apiRoutes: RouteMatcherParam | undefined): ((req: WithClerkUrl) => boolean) => { +const createApiRoutes = ( + apiRoutes: RouteMatcherParam | undefined, +): ((req: WithExperimentalClerkUrl) => boolean) => { if (apiRoutes) { return createRouteMatcher(apiRoutes); } const isDefaultApiRoute = createRouteMatcher(DEFAULT_API_ROUTES); - return (req: WithClerkUrl) => + return (req: WithExperimentalClerkUrl) => isDefaultApiRoute(req) || isRequestMethodIndicatingApiRoute(req) || isRequestContentTypeJson(req); }; @@ -364,7 +302,10 @@ const isRequestMethodIndicatingApiRoute = (req: NextRequest): boolean => { return !['get', 'head', 'options'].includes(requestMethod); }; -const withNormalizedClerkUrl = (clerkRequest: ClerkRequest, nextRequest: NextRequest): WithClerkUrl => { +const withNormalizedClerkUrl = ( + clerkRequest: ClerkRequest, + nextRequest: NextRequest, +): WithExperimentalClerkUrl => { const res = nextRequest.nextUrl.clone(); res.port = clerkRequest.clerkUrl.port; res.protocol = clerkRequest.clerkUrl.protocol; diff --git a/packages/nextjs/src/server/clerkMiddleware.test.ts b/packages/nextjs/src/server/clerkMiddleware.test.ts new file mode 100644 index 00000000000..c03142d2e40 --- /dev/null +++ b/packages/nextjs/src/server/clerkMiddleware.test.ts @@ -0,0 +1,456 @@ +// There is no need to execute the complete authenticateRequest to test authMiddleware +// This mock SHOULD exist before the import of authenticateRequest +import { AuthStatus, constants } from '@clerk/backend/internal'; +import { describe, expect } from '@jest/globals'; +import type { NextFetchEvent } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; + +const authenticateRequestMock = jest.fn().mockResolvedValue({ + toAuth: () => ({}), + headers: new Headers(), +}); + +jest.mock('./clerkClient', () => { + return { + clerkClient: { + authenticateRequest: authenticateRequestMock, + telemetry: { record: jest.fn() }, + }, + }; +}); + +// used to assert the mock +import { clerkClient } from './clerkClient'; +import { clerkMiddleware } from './clerkMiddleware'; +import { createRouteMatcher } from './routeMatcher'; + +/** + * Disable console warnings about config matchers + */ +const consoleWarn = console.warn; +global.console.warn = jest.fn(); +beforeAll(() => { + global.console.warn = jest.fn(); +}); +afterAll(() => { + global.console.warn = consoleWarn; +}); + +// Removing this mock will cause the authMiddleware tests to fail due to missing publishable key +// This mock SHOULD exist before the imports +jest.mock('./constants', () => { + return { + PUBLISHABLE_KEY: 'pk_test_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA', + SECRET_KEY: 'sk_test_xxxxxxxxxxxxxxxxxx', + }; +}); + +type MockRequestParams = { + url: string; + appendDevBrowserCookie?: boolean; + method?: string; + headers?: any; +}; + +const mockRequest = (params: MockRequestParams) => { + const { url, appendDevBrowserCookie = false, method = 'GET', headers = new Headers() } = params; + const headersWithCookie = new Headers(headers); + if (appendDevBrowserCookie) { + headersWithCookie.append('cookie', '__clerk_db_jwt=test_jwt'); + } + return new NextRequest(new URL(url, 'https://www.clerk.com').toString(), { method, headers: headersWithCookie }); +}; + +describe('ClerkMiddleware type tests', () => { + // create a copy to test the types only + // running this function does nothing, it is used purely for type checking + const clerkMiddlewareMock = jest.fn() as typeof clerkMiddleware; + it('can receive the appropriate keys', () => { + clerkMiddlewareMock({ publishableKey: '', secretKey: '' }); + clerkMiddlewareMock({ secretKey: '' }); + }); + + it('fails for unknown props', () => { + // @ts-expect-error - unknown prop + clerkMiddlewareMock({ hello: '' }); + }); + + it('can be used with a handler and an optional options object', () => { + clerkMiddlewareMock( + (auth, request, event) => { + auth().getToken(); + request.cookies.clear(); + event.sourcePage; + }, + { secretKey: '', publishableKey: '' }, + ); + }); + + it('can be used with just a handler and an optional options object', () => { + clerkMiddlewareMock((auth, request, event) => { + auth().getToken(); + request.cookies.clear(); + event.sourcePage; + }); + }); + + it('can be used with just an optional options object', () => { + clerkMiddlewareMock({ secretKey: '', publishableKey: '' }); + clerkMiddlewareMock(); + }); + + describe('Multi domain', () => { + const defaultProps = { publishableKey: '', secretKey: '' }; + + it('proxyUrl (primary app)', () => { + clerkMiddlewareMock({ ...defaultProps, proxyUrl: 'test' }); + }); + + it('proxyUrl + isSatellite (satellite app)', () => { + clerkMiddlewareMock({ ...defaultProps, proxyUrl: 'test', isSatellite: true }); + }); + + it('domain + isSatellite (satellite app)', () => { + clerkMiddlewareMock({ ...defaultProps, domain: 'test', isSatellite: true }); + }); + }); +}); + +describe('createRouteMatcher', () => { + describe('should work with path patterns', function () { + it('matches path and all sub paths using *', () => { + const isPublicRoute = createRouteMatcher(['/hello(.*)']); + expect(isPublicRoute(mockRequest({ url: '/hello' }))).toBe(true); + expect(isPublicRoute(mockRequest({ url: '/hello' }))).toBe(true); + expect(isPublicRoute(mockRequest({ url: '/hello/test/a' }))).toBe(true); + }); + + it('matches filenames with specific extensions', () => { + const isPublicRoute = createRouteMatcher(['/(.*).ts', '/(.*).js']); + expect(isPublicRoute(mockRequest({ url: '/hello.js' }))).toBe(true); + expect(isPublicRoute(mockRequest({ url: '/test/hello.js' }))).toBe(true); + expect(isPublicRoute(mockRequest({ url: '/test/hello.ts' }))).toBe(true); + }); + + it('works with single values (non array)', () => { + const isPublicRoute = createRouteMatcher('/test/hello.ts'); + expect(isPublicRoute(mockRequest({ url: '/hello.js' }))).not.toBe(true); + expect(isPublicRoute(mockRequest({ url: '/test/hello.js' }))).not.toBe(true); + }); + }); + + describe('should work with regex patterns', function () { + it('matches path and all sub paths using *', () => { + const isPublicRoute = createRouteMatcher([/^\/hello.*$/]); + expect(isPublicRoute(mockRequest({ url: '/hello' }))).toBe(true); + expect(isPublicRoute(mockRequest({ url: '/hello/' }))).toBe(true); + expect(isPublicRoute(mockRequest({ url: '/hello/test/a' }))).toBe(true); + }); + + it('matches filenames with specific extensions', () => { + const isPublicRoute = createRouteMatcher([/^.*\.(ts|js)$/]); + expect(isPublicRoute(mockRequest({ url: '/hello.js' }))).toBe(true); + expect(isPublicRoute(mockRequest({ url: '/test/hello.js' }))).toBe(true); + expect(isPublicRoute(mockRequest({ url: '/test/hello.ts' }))).toBe(true); + }); + + it('works with single values (non array)', () => { + const isPublicRoute = createRouteMatcher(/hello/g); + expect(isPublicRoute(mockRequest({ url: '/hello.js' }))).toBe(true); + expect(isPublicRoute(mockRequest({ url: '/test/hello.js' }))).toBe(true); + }); + }); +}); + +describe('authenticateRequest & handshake', () => { + beforeEach(() => { + authenticateRequestMock.mockClear(); + }); + + it('returns 307 and starts the handshake flow for handshake requestState status', async () => { + const mockLocationUrl = 'https://example.com'; + authenticateRequestMock.mockResolvedValueOnce({ + status: AuthStatus.Handshake, + headers: new Headers({ Location: mockLocationUrl }), + }); + const resp = await clerkMiddleware()(mockRequest({ url: '/protected' }), {} as NextFetchEvent); + expect(resp?.status).toEqual(307); + expect(resp?.headers.get('Location')).toEqual(mockLocationUrl); + }); +}); + +describe('authMiddleware(params)', () => { + it('renders route as normally when used without params', async () => { + const signInResp = await clerkMiddleware()(mockRequest({ url: '/sign-in' }), {} as NextFetchEvent); + expect(signInResp?.status).toEqual(200); + expect(signInResp?.headers.get('x-middleware-rewrite')).toEqual('https://www.clerk.com/sign-in'); + }); + + it('executes handler and renders route when used with a custom handler', async () => { + const signInResp = await clerkMiddleware((_, request) => { + expect(request.url).toContain('/sign-in'); + return NextResponse.next({ headers: { 'a-custom-header': '1' } }); + })(mockRequest({ url: '/sign-in' }), {} as NextFetchEvent); + expect(signInResp?.status).toEqual(200); + expect(signInResp?.headers.get('x-middleware-rewrite')).toEqual('https://www.clerk.com/sign-in'); + expect(signInResp?.headers.get('a-custom-header')).toEqual('1'); + }); + + it('renders route when when exported directly without being called', async () => { + // This is equivalent to export default clerkMiddleware; + const signInResp = await clerkMiddleware(mockRequest({ url: '/sign-in' }), {} as NextFetchEvent); + expect(signInResp?.status).toEqual(200); + expect(signInResp?.headers.get('x-middleware-rewrite')).toEqual('https://www.clerk.com/sign-in'); + }); + + it('executes handler and respects any redirects returned by the user', async () => { + const signInResp = await clerkMiddleware((_, request) => { + expect(request.url).toContain('/sign-in'); + return NextResponse.redirect('https://www.clerk.com/hello', { headers: { 'a-custom-header': '1' } }); + })(mockRequest({ url: '/sign-in' }), {} as NextFetchEvent); + expect(signInResp?.status).toEqual(307); + expect(signInResp?.headers.get(constants.Headers.Location)).toEqual('https://www.clerk.com/hello'); + expect(signInResp?.headers.get('a-custom-header')).toEqual('1'); + expect(signInResp?.headers.get(constants.Headers.AuthReason)).toBeTruthy(); + }); + + describe('auth().redirectToSignIn()', () => { + it('redirects to sign-in url when redirectToSignIn is calle and the request is a page request', async () => { + const req = mockRequest({ + url: '/protected', + headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }), + appendDevBrowserCookie: true, + }); + + authenticateRequestMock.mockResolvedValueOnce({ + status: AuthStatus.SignedOut, + headers: new Headers(), + toAuth: () => ({ userId: null }), + }); + + const resp = await clerkMiddleware(auth => { + return auth().redirectToSignIn(); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(307); + expect(resp?.headers.get('location')).toContain('sign-in'); + expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect'); + expect(clerkClient.authenticateRequest).toBeCalled(); + }); + + it('redirects to sign-in url when redirectToSignIn is calle and the request is not a page request', async () => { + const req = mockRequest({ + url: '/protected', + headers: new Headers(), + appendDevBrowserCookie: true, + }); + + authenticateRequestMock.mockResolvedValueOnce({ + status: AuthStatus.SignedOut, + headers: new Headers(), + toAuth: () => ({ userId: null }), + }); + + const resp = await clerkMiddleware(auth => { + return auth().redirectToSignIn(); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(307); + expect(resp?.headers.get('location')).toContain('sign-in'); + expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect'); + expect(clerkClient.authenticateRequest).toBeCalled(); + }); + }); + + describe('auth().protect()', () => { + it('redirects to sign-in url when protect is called, the user is signed out and the request is a page request', async () => { + const req = mockRequest({ + url: '/protected', + headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }), + appendDevBrowserCookie: true, + }); + + authenticateRequestMock.mockResolvedValueOnce({ + status: AuthStatus.SignedOut, + headers: new Headers(), + toAuth: () => ({ userId: null }), + }); + + const resp = await clerkMiddleware(auth => { + auth().protect(); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(307); + expect(resp?.headers.get('location')).toContain('sign-in'); + expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect'); + expect(clerkClient.authenticateRequest).toBeCalled(); + }); + + it('does not redirect to sign-in url when protect is called, the user is signed in and the request is a page request', async () => { + const req = mockRequest({ + url: '/protected', + headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }), + appendDevBrowserCookie: true, + }); + + authenticateRequestMock.mockResolvedValueOnce({ + status: AuthStatus.SignedIn, + headers: new Headers(), + toAuth: () => ({ userId: 'user-id' }), + }); + + const resp = await clerkMiddleware(auth => { + auth().protect(); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(200); + expect(resp?.headers.get('location')).toBeFalsy(); + expect(clerkClient.authenticateRequest).toBeCalled(); + }); + + it('throws a not found error when protect is called, the user is signed out, and is not a page request', async () => { + const req = mockRequest({ + url: '/protected', + headers: new Headers(), + appendDevBrowserCookie: true, + }); + + authenticateRequestMock.mockResolvedValueOnce({ + status: AuthStatus.SignedOut, + headers: new Headers(), + toAuth: () => ({ userId: null }), + }); + + const resp = await clerkMiddleware(auth => { + auth().protect(); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(200); + expect(resp?.headers.get(constants.Headers.AuthReason)).toContain('protect-rewrite'); + expect(clerkClient.authenticateRequest).toBeCalled(); + }); + + it('throws a not found error when protect is called with RBAC params the user does not fulfil, and is a page request', async () => { + const req = mockRequest({ + url: '/protected', + headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }), + appendDevBrowserCookie: true, + }); + + authenticateRequestMock.mockResolvedValueOnce({ + status: AuthStatus.SignedIn, + headers: new Headers(), + toAuth: () => ({ userId: 'user-id', has: () => false }), + }); + + const resp = await clerkMiddleware(auth => { + auth().protect({ role: 'random-role' }); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(200); + expect(resp?.headers.get(constants.Headers.AuthReason)).toContain('protect-rewrite'); + expect(clerkClient.authenticateRequest).toBeCalled(); + }); + + it('redirects to redirectUrl when protect is called with the redirectUrl param, the user is signed out, and is a page request', async () => { + const req = mockRequest({ + url: '/protected', + headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }), + appendDevBrowserCookie: true, + }); + + authenticateRequestMock.mockResolvedValueOnce({ + status: AuthStatus.SignedOut, + headers: new Headers(), + toAuth: () => ({ userId: null }), + }); + + const resp = await clerkMiddleware(auth => { + auth().protect({ redirectUrl: 'https://www.clerk.com/hello' }); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(307); + expect(resp?.headers.get('location')).toContain('https://www.clerk.com/hello'); + expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect'); + expect(resp?.headers.get(constants.Headers.ClerkRedirectTo)).toEqual('true'); + expect(clerkClient.authenticateRequest).toBeCalled(); + }); + }); +}); + +describe('Dev Browser JWT when redirecting to cross origin for page requests', function () { + it('does NOT append the Dev Browser JWT when cookie is missing', async () => { + const req = mockRequest({ + url: '/protected', + headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }), + appendDevBrowserCookie: false, + }); + + authenticateRequestMock.mockResolvedValueOnce({ + status: AuthStatus.SignedOut, + headers: new Headers(), + toAuth: () => ({ userId: null }), + }); + + const resp = await clerkMiddleware(auth => { + auth().protect(); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(307); + expect(resp?.headers.get('location')).toEqual( + 'https://accounts.included.katydid-92.lcl.dev/sign-in?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fprotected', + ); + expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect'); + expect(clerkClient.authenticateRequest).toBeCalled(); + }); + + it('appends the Dev Browser JWT to the search when cookie __clerk_db_jwt exists and location is an Account Portal URL', async () => { + const req = mockRequest({ + url: '/protected', + headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }), + appendDevBrowserCookie: true, + }); + + authenticateRequestMock.mockResolvedValueOnce({ + status: AuthStatus.SignedOut, + headers: new Headers(), + toAuth: () => ({ userId: null }), + }); + + const resp = await clerkMiddleware(auth => { + auth().protect(); + })(req, {} as NextFetchEvent); + expect(resp?.status).toEqual(307); + expect(resp?.headers.get('location')).toEqual( + 'https://accounts.included.katydid-92.lcl.dev/sign-in?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fprotected&__clerk_db_jwt=test_jwt', + ); + expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect'); + expect(clerkClient.authenticateRequest).toBeCalled(); + }); + + it('does NOT append the Dev Browser JWT if x-clerk-redirect-to header is not set (user-returned redirect)', async () => { + const req = mockRequest({ + url: '/protected', + headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }), + appendDevBrowserCookie: true, + }); + + authenticateRequestMock.mockResolvedValueOnce({ + status: AuthStatus.SignedOut, + headers: new Headers(), + toAuth: () => ({ userId: null }), + }); + + const resp = await clerkMiddleware(() => { + return NextResponse.redirect( + 'https://accounts.included.katydid-92.lcl.dev/sign-in?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fprotected', + ); + })(req, {} as NextFetchEvent); + expect(resp?.status).toEqual(307); + expect(resp?.headers.get('location')).toEqual( + 'https://accounts.included.katydid-92.lcl.dev/sign-in?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fprotected', + ); + expect(resp?.headers.get('x-clerk-auth-reason')).toEqual('redirect'); + expect(clerkClient.authenticateRequest).toBeCalled(); + }); +}); diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts new file mode 100644 index 00000000000..31dbb67aeff --- /dev/null +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -0,0 +1,200 @@ +import type { AuthenticateRequestOptions, AuthObject, ClerkRequest, RequestState } from '@clerk/backend/internal'; +import { AuthStatus, constants, createClerkRequest, redirect } from '@clerk/backend/internal'; +import type { NextMiddleware } from 'next/server'; +import { NextResponse } from 'next/server'; + +import { isRedirect, serverRedirectWithAuth, setHeader } from '../utils'; +import { clerkClient } from './clerkClient'; +import { PUBLISHABLE_KEY, SECRET_KEY } from './constants'; +import type { AuthProtect } from './protect'; +import { createProtect } from './protect'; +import type { NextMiddlewareEvtParam, NextMiddlewareRequestParam, NextMiddlewareReturn } from './types'; +import { + decorateRequest, + decorateResponseWithObservabilityHeaders, + handleMultiDomainAndProxy, + setRequestHeadersOnNextResponse, +} from './utils'; + +const PROTECT_REWRITE = 'CLERK_PROTECT_REWRITE'; +const PROTECT_REDIRECT_TO_URL = 'CLERK_PROTECT_REDIRECT_TO_URL'; +const PROTECT_REDIRECT_TO_SIGN_IN = 'CLERK_PROTECT_REDIRECT_TO_SIGN_IN'; + +type ClerkMiddlewareAuthObject = AuthObject & { + protect: AuthProtect; + redirectToSignIn: (opts?: { returnBackUrl?: URL | string | null }) => Response; +}; + +type ClerkMiddlewareHandler = ( + auth: () => ClerkMiddlewareAuthObject, + request: NextMiddlewareRequestParam, + event: NextMiddlewareEvtParam, +) => NextMiddlewareReturn; + +type ClerkMiddlewareOptions = AuthenticateRequestOptions & { debug?: boolean }; + +/** + * Middleware for Next.js that handles authentication and authorization with Clerk. + * For more details, please refer to the docs: https://clerk.com/docs/references/nextjs/clerkMiddleware + */ +interface ClerkMiddleware { + /** + * @example + * export default clerkMiddleware((auth, request, event) => { ... }, options); + */ + (handler: ClerkMiddlewareHandler, options?: ClerkMiddlewareOptions): NextMiddleware; + /** + * @example + * export default clerkMiddleware(options); + */ + (options?: ClerkMiddlewareOptions): NextMiddleware; + /** + * @example + * export default clerkMiddleware; + */ + (request: NextMiddlewareRequestParam, event: NextMiddlewareEvtParam): NextMiddlewareReturn; +} + +export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]): any => { + const [request, event] = parseRequestAndEvent(args); + const [handler, options] = parseHandlerAndOptions(args); + + const nextMiddleware: NextMiddleware = async (request, event) => { + const clerkRequest = createClerkRequest(request); + + const requestState = await clerkClient.authenticateRequest( + clerkRequest, + createAuthenticateRequestOptions(clerkRequest, options), + ); + + const locationHeader = requestState.headers.get(constants.Headers.Location); + if (locationHeader) { + const res = new Response(null, { status: 307, headers: requestState.headers }); + return decorateResponseWithObservabilityHeaders(res, requestState); + } else if (requestState.status === AuthStatus.Handshake) { + throw new Error('Clerk: handshake status without redirect'); + } + + const authObject = requestState.toAuth(); + + const authObjWithMethods: ClerkMiddlewareAuthObject = Object.assign(authObject, { + protect: createMiddlewareProtect(clerkRequest, authObject), + redirectToSignIn: createMiddlewareRedirectToSignIn(clerkRequest, requestState), + }); + + let handlerResult: Response = NextResponse.next(); + try { + handlerResult = (await handler?.(() => authObjWithMethods, request, event)) || handlerResult; + } catch (e: any) { + switch (e.message) { + case PROTECT_REWRITE: + // Rewrite to a bogus URL to force not found error + handlerResult = NextResponse.rewrite(`${clerkRequest.clerkUrl.origin}/clerk_${Date.now()}`); + setHeader(handlerResult, constants.Headers.AuthReason, 'protect-rewrite'); + break; + case PROTECT_REDIRECT_TO_URL: + handlerResult = redirectAdapter(e.redirectUrl); + break; + case PROTECT_REDIRECT_TO_SIGN_IN: + handlerResult = authObjWithMethods.redirectToSignIn(); + break; + default: + throw e; + } + } + + if (isRedirect(handlerResult)) { + const res = setHeader(handlerResult, constants.Headers.AuthReason, 'redirect'); + return serverRedirectWithAuth(clerkRequest, res, options); + } + + if (options.debug) { + setRequestHeadersOnNextResponse(handlerResult, clerkRequest, { [constants.Headers.EnableDebug]: 'true' }); + } + + decorateRequest(clerkRequest, handlerResult, requestState); + if (requestState.headers) { + requestState.headers.forEach((value, key) => { + handlerResult.headers.append(key, value); + }); + } + + return handlerResult; + }; + + // If we have a request and event, we're being called as a middleware directly + // eg, export default clerkMiddleware; + if (request && event) { + return nextMiddleware(request, event); + } + + // Otherwise, return a middleware that can be called with a request and event + // eg, export default clerkMiddleware(auth => { ... }); + return nextMiddleware; +}; + +const parseRequestAndEvent = (args: unknown[]) => { + return [args[0] instanceof Request ? args[0] : undefined, args[0] instanceof Request ? args[1] : undefined] as [ + NextMiddlewareRequestParam | undefined, + NextMiddlewareEvtParam | undefined, + ]; +}; + +const parseHandlerAndOptions = (args: unknown[]) => { + return [ + typeof args[0] === 'function' ? args[0] : undefined, + (args.length === 2 ? args[1] : typeof args[0] === 'function' ? {} : args[0]) || {}, + ] as [ClerkMiddlewareHandler | undefined, ClerkMiddlewareOptions]; +}; + +export const createAuthenticateRequestOptions = (clerkRequest: ClerkRequest, options: ClerkMiddlewareOptions) => { + return { + ...options, + secretKey: options.secretKey || SECRET_KEY, + publishableKey: options.publishableKey || PUBLISHABLE_KEY, + ...handleMultiDomainAndProxy(clerkRequest, options), + }; +}; + +const redirectAdapter = (url: string | URL) => { + const res = NextResponse.redirect(url); + return setHeader(res, constants.Headers.ClerkRedirectTo, 'true'); +}; + +const createMiddlewareRedirectToSignIn = ( + clerkRequest: ClerkRequest, + requestState: RequestState, +): ClerkMiddlewareAuthObject['redirectToSignIn'] => { + return (opts = {}) => { + return redirect({ + redirectAdapter, + signInUrl: requestState.signInUrl, + signUpUrl: requestState.signUpUrl, + publishableKey: PUBLISHABLE_KEY, + }).redirectToSignIn({ returnBackUrl: opts.returnBackUrl === null ? '' : clerkRequest.clerkUrl.toString() }); + }; +}; + +const createMiddlewareProtect = ( + clerkRequest: ClerkRequest, + authObject: AuthObject, +): ClerkMiddlewareAuthObject['protect'] => { + return ((params, options) => { + const notFound = () => { + throw new Error(PROTECT_REWRITE) as any; + }; + + const redirect = (url: string) => { + const err = new Error(PROTECT_REDIRECT_TO_URL) as any; + err.redirectUrl = url; + throw err; + }; + + const redirectToSignIn = () => { + throw new Error(PROTECT_REDIRECT_TO_SIGN_IN) as any; + }; + + // @ts-expect-error TS is not happy even though the types are correct + return createProtect({ request: clerkRequest, redirect, notFound, authObject, redirectToSignIn })(params, options); + }) as AuthProtect; +}; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 908b4cbc448..58aa59ae55e 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -1,6 +1,8 @@ /** * Generic exports */ +import { createRouteMatcher } from './routeMatcher'; + export { verifyToken, createClerkClient } from '@clerk/backend'; export type { WebhookEvent, WebhookEventType } from '@clerk/backend'; export { clerkClient } from './clerkClient'; @@ -13,3 +15,14 @@ export { redirectToSignIn, redirectToSignUp } from './redirect'; export { auth } from '../app-router/server/auth'; export { currentUser } from '../app-router/server/currentUser'; export { authMiddleware } from './authMiddleware'; +export { clerkMiddleware } from './clerkMiddleware'; + +/** + * Returns a function that accepts a `Request` object and returns whether the request matches the list of + * predefined routes that can be passed in as the first argument. + * + * You can use glob patterns to match multiple routes or a function to match against the request object. + * Path patterns and regular expressions are supported, for example: `['/foo', '/bar(.*)'] or `[/^\/foo\/.*$/]` + * For more information, see: https://clerk.com/docs + */ +export const experimental_createRouteMatcher = createRouteMatcher; diff --git a/packages/nextjs/src/server/protect.ts b/packages/nextjs/src/server/protect.ts new file mode 100644 index 00000000000..0ebdf05a10b --- /dev/null +++ b/packages/nextjs/src/server/protect.ts @@ -0,0 +1,130 @@ +import type { AuthObject, SignedInAuthObject } from '@clerk/backend/internal'; +import { constants } from '@clerk/backend/internal'; +import type { + CheckAuthorizationParamsWithCustomPermissions, + CheckAuthorizationWithCustomPermissions, +} from '@clerk/types'; + +import { constants as nextConstants } from '../constants'; +import { SIGN_IN_URL } from './constants'; + +type AuthProtectOptions = { redirectUrl?: string }; + +/** + * @experimental + * This function is experimental as it throws a Nextjs notFound error if user is not authenticated or authorized. + * In the future we would investigate a way to throw a more appropriate error that clearly describes the not authorized of authenticated status. + */ +export interface AuthProtect { + (params?: CheckAuthorizationParamsWithCustomPermissions, options?: AuthProtectOptions): SignedInAuthObject; + ( + params?: (has: CheckAuthorizationWithCustomPermissions) => boolean, + options?: AuthProtectOptions, + ): SignedInAuthObject; + (options?: AuthProtectOptions): SignedInAuthObject; +} + +export const createProtect = (opts: { + request: Request; + authObject: AuthObject; + /** + * middleware and pages throw a notFound error if signed out + * but the middleware needs to throw an error it can catch + * use this callback to customise the behavior + */ + notFound: () => never; + /** + * see {@link notFound} above + */ + redirect: (url: string) => void; + /** + * protect() in middleware redirects to signInUrl if signed out + * protect() in pages throws a notFound error if signed out + * use this callback to customise the behavior + */ + redirectToSignIn?: () => void; +}): AuthProtect => { + const { redirectToSignIn, authObject, redirect, notFound, request } = opts; + + return ((...args: any[]) => { + const paramsOrFunction = args[0]?.redirectUrl + ? undefined + : (args[0] as + | CheckAuthorizationParamsWithCustomPermissions + | ((has: CheckAuthorizationWithCustomPermissions) => boolean)); + const redirectUrl = (args[0]?.redirectUrl || args[1]?.redirectUrl) as string | undefined; + + const handleUnauthenticated = () => { + if (redirectUrl) { + return redirect(redirectUrl); + } + if (isPageRequest(request)) { + // TODO: Handle runtime values. What happens if runtime values are set in middleware and in ClerkProvider as well? + return redirectToSignIn ? redirectToSignIn() : redirect(SIGN_IN_URL); + } + return notFound(); + }; + + const handleUnauthorized = () => { + if (redirectUrl) { + return redirect(redirectUrl); + } + return notFound(); + }; + + /** + * User is not authenticated + */ + if (!authObject.userId) { + return handleUnauthenticated(); + } + + /** + * User is authenticated + */ + if (!paramsOrFunction) { + return authObject; + } + + /** + * if a function is passed and returns false then throw not found + */ + if (typeof paramsOrFunction === 'function') { + if (paramsOrFunction(authObject.has)) { + return authObject; + } + return handleUnauthorized(); + } + + /** + * Checking if user is authorized when permission or role is passed + */ + if (authObject.has(paramsOrFunction)) { + return authObject; + } + + return handleUnauthorized(); + }) as AuthProtect; +}; + +const isServerActionRequest = (req: Request) => { + return ( + !!req.headers.get(nextConstants.Headers.NextUrl) && + (req.headers.get(constants.Headers.Accept)?.includes('text/x-component') || + req.headers.get(constants.Headers.ContentType)?.includes('multipart/form-data') || + !!req.headers.get(nextConstants.Headers.NextAction)) + ); +}; + +const isPageRequest = (req: Request): boolean => { + return ( + req.headers.get(constants.Headers.SecFetchDest) === 'document' || + req.headers.get(constants.Headers.Accept)?.includes('text/html') || + (!!req.headers.get(nextConstants.Headers.NextUrl) && !isServerActionRequest(req)) + ); +}; + +// In case we want to handle router handlers and server actions differently in the future +// const isRouteHandler = (req: Request) => { +// return !isPageRequest(req) && !isServerAction(req); +// }; diff --git a/packages/nextjs/src/server/routeMatcher.ts b/packages/nextjs/src/server/routeMatcher.ts new file mode 100644 index 00000000000..c52216b9c82 --- /dev/null +++ b/packages/nextjs/src/server/routeMatcher.ts @@ -0,0 +1,40 @@ +import type { Autocomplete } from '@clerk/types'; +import type Link from 'next/link'; +import type { NextRequest } from 'next/server'; + +import { paths } from '../utils'; + +type WithPathPatternWildcard = `${T & string}(.*)`; +type NextTypedRoute['0']['href']> = T extends string ? T : never; + +// For extra safety, we won't recommend using a `/(.*)` route matcher. +type ExcludeRootPath = T extends '/' ? never : T; + +type RouteMatcherWithNextTypedRoutes = Autocomplete< + WithPathPatternWildcard> | NextTypedRoute +>; + +export type RouteMatcherParam = + | Array + | RegExp + | RouteMatcherWithNextTypedRoutes + | ((req: NextRequest) => boolean); + +/** + * Create a function that matches a request against the specified routes. + * Precomputes the glob matchers for the public routes, so we don't have to + * recompile the regular expressions on every request. + */ +export const createRouteMatcher = (routes: RouteMatcherParam) => { + if (typeof routes === 'function') { + return (req: NextRequest) => routes(req); + } + + const routePatterns = [routes || ''].flat().filter(Boolean); + const matchers = precomputePathRegex(routePatterns); + return (req: NextRequest) => matchers.some(matcher => matcher.test(req.nextUrl.pathname)); +}; + +const precomputePathRegex = (patterns: Array) => { + return patterns.map(pattern => (pattern instanceof RegExp ? pattern : paths.toRegexp(pattern))); +}; diff --git a/packages/nextjs/src/server/types.ts b/packages/nextjs/src/server/types.ts index bb395c543ee..b1f15bc79fd 100644 --- a/packages/nextjs/src/server/types.ts +++ b/packages/nextjs/src/server/types.ts @@ -4,10 +4,10 @@ import type { NextApiRequestCookies } from 'next/dist/server/api-utils'; import type { NextMiddleware, NextRequest } from 'next/server'; // Request contained in GetServerSidePropsContext, has cookies but not query -type GsspRequest = IncomingMessage & { - cookies: NextApiRequestCookies; -}; +type GsspRequest = IncomingMessage & { cookies: NextApiRequestCookies }; export type RequestLike = NextRequest | NextApiRequest | GsspRequest; -export type NextMiddlewareResult = Awaited>; +export type NextMiddlewareRequestParam = Parameters['0']; +export type NextMiddlewareEvtParam = Parameters['1']; +export type NextMiddlewareReturn = ReturnType; diff --git a/packages/nextjs/src/types.ts b/packages/nextjs/src/types.ts index e8d7fe4aa74..d0eb56cbe64 100644 --- a/packages/nextjs/src/types.ts +++ b/packages/nextjs/src/types.ts @@ -1,6 +1,12 @@ -import type { ClerkProviderOptionsWrapper } from '@clerk/clerk-react'; +import type { ClerkProviderProps } from '@clerk/clerk-react'; +import type { Without } from '@clerk/types'; -export type NextClerkProviderProps = { +export type NextClerkProviderProps = Without & { + /** + * Used to override the default NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY env variable if needed. + * This is optional for NextJS as the ClerkProvider will automatically use the NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY env variable if it exists. + */ + publishableKey?: string; /** * If set to true, the NextJS middleware will be invoked * every time the client-side auth state changes (sign-out, sign-in, organization switch etc.). @@ -10,4 +16,4 @@ export type NextClerkProviderProps = { * @default true */ __unstable_invokeMiddlewareOnAuthStateChange?: boolean; -} & ClerkProviderOptionsWrapper; +}; diff --git a/packages/nextjs/src/utils/index.ts b/packages/nextjs/src/utils/index.ts index e4315a8fc20..4b57a0937f0 100644 --- a/packages/nextjs/src/utils/index.ts +++ b/packages/nextjs/src/utils/index.ts @@ -1,2 +1,3 @@ export * from './pathMatchers'; export * from './response'; +export * from './serverRedirectWithAuth'; diff --git a/packages/nextjs/src/utils/serverRedirectWithAuth.ts b/packages/nextjs/src/utils/serverRedirectWithAuth.ts new file mode 100644 index 00000000000..90bc2136337 --- /dev/null +++ b/packages/nextjs/src/utils/serverRedirectWithAuth.ts @@ -0,0 +1,29 @@ +// Middleware runs on the server side, before clerk-js is loaded, that's why we need Cookies. +import type { AuthenticateRequestOptions, ClerkRequest } from '@clerk/backend/internal'; +import { constants } from '@clerk/backend/internal'; +import { DEV_BROWSER_JWT_KEY, isDevelopmentFromSecretKey, setDevBrowserJWTInURL } from '@clerk/shared'; +import { NextResponse } from 'next/server'; + +import { SECRET_KEY } from '../server/constants'; + +/** + * Grabs the dev browser JWT from cookies and appends it to the redirect URL when redirecting to cross-origin. + */ +export const serverRedirectWithAuth = (clerkRequest: ClerkRequest, res: Response, opts: AuthenticateRequestOptions) => { + const location = res.headers.get('location'); + const shouldAppendDevBrowser = res.headers.get(constants.Headers.ClerkRedirectTo) === 'true'; + + if ( + shouldAppendDevBrowser && + !!location && + isDevelopmentFromSecretKey(opts.secretKey || SECRET_KEY) && + clerkRequest.clerkUrl.isCrossOrigin(location) + ) { + const dbJwt = clerkRequest.cookies.get(DEV_BROWSER_JWT_KEY) || ''; + // Next.js 12.1+ allows redirects only to absolute URLs + const url = new URL(location); + const urlWithDevBrowser = setDevBrowserJWTInURL(url, dbJwt); + return NextResponse.redirect(urlWithDevBrowser.href, res); + } + return res; +}; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 3d900e88ad5..091b93e1c6b 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -7,12 +7,6 @@ export * from './contexts'; export * from './hooks'; export { useEmailLink } from './hooks/useEmailLink'; -export type { - BrowserClerk, - ClerkProp, - HeadlessBrowserClerk, - ClerkProviderOptionsWrapper, - ClerkProviderProps, -} from './types'; +export type { BrowserClerk, ClerkProp, HeadlessBrowserClerk, ClerkProviderProps } from './types'; setErrorThrowerOptions({ packageName: PACKAGE_NAME }); diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index fa52248638a..ab4766e200c 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -36,11 +36,6 @@ export type ClerkProviderProps = IsomorphicClerkOptions & { initialState?: InitialState; }; -export type ClerkProviderOptionsWrapper = Without & { - publishableKey?: string; - children: React.ReactNode; -}; - export interface BrowserClerkConstructor { new (publishableKey: string, options?: DomainOrProxyUrl): BrowserClerk; } diff --git a/packages/remix/src/client/ClerkApp.tsx b/packages/remix/src/client/ClerkApp.tsx index fdebc0d5162..a6946173ddb 100644 --- a/packages/remix/src/client/ClerkApp.tsx +++ b/packages/remix/src/client/ClerkApp.tsx @@ -1,8 +1,8 @@ import { useLoaderData } from '@remix-run/react'; import React from 'react'; -import type { RemixClerkProviderProps } from './RemixClerkProvider'; import { ClerkProvider } from './RemixClerkProvider'; +import type { RemixClerkProviderProps } from './types'; type ClerkAppOptions = Partial< Omit @@ -12,9 +12,10 @@ export function ClerkApp(App: () => JSX.Element, opts: ClerkAppOptions = {}) { return () => { const { clerkState } = useLoaderData(); return ( - // @ts-expect-error diff --git a/packages/remix/src/client/RemixClerkProvider.tsx b/packages/remix/src/client/RemixClerkProvider.tsx index 2b87492996d..6dfdef264d2 100644 --- a/packages/remix/src/client/RemixClerkProvider.tsx +++ b/packages/remix/src/client/RemixClerkProvider.tsx @@ -1,10 +1,9 @@ -import type { ClerkProviderOptionsWrapper } from '@clerk/clerk-react'; import { ClerkProvider as ReactClerkProvider } from '@clerk/clerk-react'; import React from 'react'; import { assertValidClerkState, warnForSsr } from '../utils'; import { ClerkRemixOptionsProvider } from './RemixOptionsContext'; -import type { ClerkState } from './types'; +import type { ClerkState, RemixClerkProviderProps } from './types'; import { useAwaitableNavigate } from './useAwaitableNavigate'; export * from '@clerk/clerk-react'; @@ -14,10 +13,6 @@ const SDK_METADATA = { version: PACKAGE_VERSION, }; -export type RemixClerkProviderProps = { - clerkState: ClerkState; -} & ClerkProviderOptionsWrapper; - /** * Remix hydration errors should not stop Clerk navigation from working, as the components mount only after * hydration is done (in the case of a hydration error, the components will simply mount after client-side hydration) @@ -31,7 +26,16 @@ export type RemixClerkProviderProps = { */ const awaitableNavigateRef: { current: ReturnType | undefined } = { current: undefined }; -export function ClerkProvider({ children, ...rest }: RemixClerkProviderProps): JSX.Element { +/** + * Internal type that includes the initial state prop that is passed to the ClerkProvider + * during SSR. + * This is a value that we pass automatically so it does not need to pollute the public API. + */ +type ClerkProviderPropsWithState = RemixClerkProviderProps & { + clerkState: ClerkState; +}; + +export function ClerkProvider({ children, ...rest }: ClerkProviderPropsWithState): JSX.Element { const awaitableNavigate = useAwaitableNavigate(); React.useEffect(() => { diff --git a/packages/remix/src/client/types.ts b/packages/remix/src/client/types.ts index 327c07351fa..7ac8d137ad6 100644 --- a/packages/remix/src/client/types.ts +++ b/packages/remix/src/client/types.ts @@ -1,5 +1,6 @@ -import type { ClerkProviderOptionsWrapper } from '@clerk/clerk-react'; -import type { InitialState } from '@clerk/types'; +import type { ClerkProviderProps } from '@clerk/clerk-react'; +import type { InitialState, Without } from '@clerk/types'; +import type React from 'react'; export type ClerkState = { __type: 'clerkState'; @@ -26,4 +27,11 @@ export type WithClerkState = { clerkState: { __type: 'clerkState' }; }; -export type RemixClerkProviderProps = ClerkProviderOptionsWrapper; +export type RemixClerkProviderProps = Without & { + /** + * Used to override the default CLERK_PUBLISHABLE_KEY env variable if needed. + * This is optional for Remix as the ClerkProvider will automatically use the CLERK_PUBLISHABLE_KEY env variable if it exists. + */ + publishableKey?: string; + children: React.ReactNode; +}; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 59ec7047a65..a5c5e46582c 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -503,20 +503,18 @@ export type ClerkThemeOptions = DeepSnakeToCamel>; * Navigation options used to replace or push history changes. * Both `routerPush` & `routerReplace` OR none options should be passed. */ -type ClerkOptionsNavigationFn = +type ClerkOptionsNavigation = | { routerPush?: never; + routerDebug?: boolean; routerReplace?: never; } | { routerPush: (to: string) => Promise | unknown; routerReplace: (to: string) => Promise | unknown; + routerDebug?: boolean; }; -type ClerkOptionsNavigation = ClerkOptionsNavigationFn & { - routerDebug?: boolean; -}; - export type ClerkOptions = ClerkOptionsNavigation & AfterActionURLs & { appearance?: Appearance;