From d77519a38abe24b07d712c3ef7ac15b9ce5a5e02 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 15 Dec 2023 16:18:36 +0200 Subject: [PATCH 1/4] fix(nextjs): Fix Protect type --- integration/tests/next-build.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/tests/next-build.test.ts b/integration/tests/next-build.test.ts index 4e03db4f80c..9c313650432 100644 --- a/integration/tests/next-build.test.ts +++ b/integration/tests/next-build.test.ts @@ -54,7 +54,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) }); test.afterAll(async () => { - await app.teardown(); + // await app.teardown(); }); test('When is used as a client component, builds successfully and does not force dynamic rendering', () => { From 0103c27af007ec5bb2baea7ea4c435476e046c41 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Sat, 16 Dec 2023 14:53:26 +0200 Subject: [PATCH 2/4] feat(backend): Drop unused checkCrossOrigin util --- integration/tests/next-build.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/tests/next-build.test.ts b/integration/tests/next-build.test.ts index 9c313650432..4e03db4f80c 100644 --- a/integration/tests/next-build.test.ts +++ b/integration/tests/next-build.test.ts @@ -54,7 +54,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) }); test.afterAll(async () => { - // await app.teardown(); + await app.teardown(); }); test('When is used as a client component, builds successfully and does not force dynamic rendering', () => { From a28222150806da3e5055e1f37a045deb13045a57 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 15 Dec 2023 04:31:26 +0200 Subject: [PATCH 3/4] feat(nextjs): Wip --- packages/backend/src/constants.ts | 3 + packages/backend/src/tokens/clerkRequest.ts | 23 +--- packages/backend/src/tokens/clerkUrl.ts | 24 ++++ .../nextjs/src/pages/__tests__/index.test.tsx | 21 --- .../nextjs/src/server/authMiddleware.test.ts | 3 +- packages/nextjs/src/server/authMiddleware.ts | 116 +++++------------ packages/nextjs/src/server/clerkMiddleware.ts | 123 ++++++++++++++++++ packages/nextjs/src/server/index.ts | 5 + packages/nextjs/src/server/routeMatcher.ts | 40 ++++++ packages/nextjs/src/server/types.ts | 8 +- .../utils/appendDevBrowserOnCrossOrigin.ts | 34 +++++ packages/remix/src/client/ClerkApp.tsx | 1 - packages/types/src/multiDomain.ts | 62 ++------- 13 files changed, 284 insertions(+), 179 deletions(-) create mode 100644 packages/backend/src/tokens/clerkUrl.ts create mode 100644 packages/nextjs/src/server/clerkMiddleware.ts create mode 100644 packages/nextjs/src/server/routeMatcher.ts create mode 100644 packages/nextjs/src/utils/appendDevBrowserOnCrossOrigin.ts 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/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/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..b332d145c6b 100644 --- a/packages/nextjs/src/server/authMiddleware.ts +++ b/packages/nextjs/src/server/authMiddleware.ts @@ -1,39 +1,28 @@ 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, setHeader, stringifyHeaders } from '../utils'; +import { appendDevBrowserOnCrossOrigin } from '../utils/appendDevBrowserOnCrossOrigin'; 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 +39,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 +56,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 +115,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 +150,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 +176,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 +198,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 +207,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 appendDevBrowserOnCrossOrigin(clerkRequest, res, options); } if (options.debug) { @@ -248,27 +229,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 +248,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 +274,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 +282,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 +303,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.ts b/packages/nextjs/src/server/clerkMiddleware.ts new file mode 100644 index 00000000000..c54c7aa986a --- /dev/null +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -0,0 +1,123 @@ +import type { AuthenticateRequestOptions, ClerkRequest } from '@clerk/backend/internal'; +import { AuthStatus, constants, createClerkRequest } from '@clerk/backend/internal'; +import type { NextMiddleware } from 'next/server'; +import { NextResponse } from 'next/server'; + +import { isRedirect, setHeader } from '../utils'; +import { appendDevBrowserOnCrossOrigin } from '../utils/appendDevBrowserOnCrossOrigin'; +import { clerkClient } from './clerkClient'; +import { PUBLISHABLE_KEY, SECRET_KEY } from './constants'; +import type { NextMiddlewareEvtParam, NextMiddlewareRequestParam, NextMiddlewareReturn } from './types'; +import { + decorateRequest, + decorateResponseWithObservabilityHeaders, + handleMultiDomainAndProxy, + setRequestHeadersOnNextResponse, +} from './utils'; + +type ClerkMiddlewareAuthObject = { + protect: () => any; + redirectToSignIn: () => any; +}; + +type ClerkMiddlewareHandler = ( + auth: ClerkMiddlewareAuthObject, + request: NextMiddlewareRequestParam, + event: NextMiddlewareEvtParam, +) => NextMiddlewareReturn; + +type ClerkMiddlewareOptions = AuthenticateRequestOptions & { debug?: boolean }; + +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 authObj = requestState.toAuth(); + const handlerResult = (await handler?.(authObj, clerkRequest, event)) || NextResponse.next(); + + if (isRedirect(handlerResult)) { + const res = setHeader(handlerResult, constants.Headers.AuthReason, 'redirect'); + return appendDevBrowserOnCrossOrigin(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), + }; +}; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 908b4cbc448..f7756f50382 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,6 @@ 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'; + +export const experimental_createRouteMatcher = createRouteMatcher; 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/utils/appendDevBrowserOnCrossOrigin.ts b/packages/nextjs/src/utils/appendDevBrowserOnCrossOrigin.ts new file mode 100644 index 00000000000..b6abd9258f5 --- /dev/null +++ b/packages/nextjs/src/utils/appendDevBrowserOnCrossOrigin.ts @@ -0,0 +1,34 @@ +// 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 appendDevBrowserOnCrossOrigin = ( + 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/remix/src/client/ClerkApp.tsx b/packages/remix/src/client/ClerkApp.tsx index fdebc0d5162..fdb96bd18d5 100644 --- a/packages/remix/src/client/ClerkApp.tsx +++ b/packages/remix/src/client/ClerkApp.tsx @@ -12,7 +12,6 @@ export function ClerkApp(App: () => JSX.Element, opts: ClerkAppOptions = {}) { return () => { const { clerkState } = useLoaderData(); return ( - // @ts-expect-error string); -/** - * DomainOrProxyUrl supports the following cases - * 1) none of them are set - * 2) only proxyUrl is set - * 3) isSatellite and proxy is set - * 4) isSatellite and domain is set - */ -export type MultiDomainAndOrProxy = - | { - isSatellite?: never; - proxyUrl?: never | StringOrURLFnToString; - domain?: never; - } - | { - isSatellite: Exclude; - proxyUrl?: never; - domain: StringOrURLFnToString; - } - | { - isSatellite: Exclude; - proxyUrl: StringOrURLFnToString; - domain?: never; - }; +export type MultiDomainAndOrProxy = { + isSatellite?: ClerkOptions['isSatellite']; + proxyUrl?: StringOrURLFnToString; + domain?: StringOrURLFnToString; +}; -export type MultiDomainAndOrProxyPrimitives = - | { - isSatellite?: never; - proxyUrl?: never | string; - domain?: never; - } - | { - isSatellite: boolean; - proxyUrl?: never; - domain: string; - } - | { - isSatellite: boolean; - proxyUrl: string; - domain?: never; - }; +export type MultiDomainAndOrProxyPrimitives = { + isSatellite?: boolean; + proxyUrl?: string; + domain?: string; +}; -export type DomainOrProxyUrl = - | { - proxyUrl?: never; - domain?: StringOrURLFnToString; - } - | { - proxyUrl?: StringOrURLFnToString; - domain?: never; - }; +export type DomainOrProxyUrl = { + proxyUrl?: StringOrURLFnToString; + domain?: StringOrURLFnToString; +}; From 29505d8a6d9937155e298a697dc6deb1af1b919b Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 19 Dec 2023 02:59:30 +0200 Subject: [PATCH 4/4] feat(nextjs): Introduce clerkMiddleware --- packages/nextjs/src/app-router/server/auth.ts | 7 +- packages/nextjs/src/server/authMiddleware.ts | 3 +- packages/nextjs/src/server/clerkMiddleware.ts | 84 ++++++++++++++++--- .../src/{app-router => }/server/protect.ts | 43 +++++++--- packages/nextjs/src/utils/index.ts | 1 + 5 files changed, 111 insertions(+), 27 deletions(-) rename packages/nextjs/src/{app-router => }/server/protect.ts (62%) diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts index 2754eef7dc3..0c049884f8d 100644 --- a/packages/nextjs/src/app-router/server/auth.ts +++ b/packages/nextjs/src/app-router/server/auth.ts @@ -1,9 +1,10 @@ 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 } => { @@ -12,7 +13,7 @@ export const auth = (): AuthObject & { protect: AuthProtect } => { noAuthStatusMessage: authAuthHeaderMissing(), })(buildRequestLike()); - return Object.assign(authObject, { protect: createProtect(authObject) }); + return Object.assign(authObject, { protect: createProtect({ notFound, redirect, authObject }) }); }; export const initialState = () => { diff --git a/packages/nextjs/src/server/authMiddleware.ts b/packages/nextjs/src/server/authMiddleware.ts index b332d145c6b..45efa1ae90a 100644 --- a/packages/nextjs/src/server/authMiddleware.ts +++ b/packages/nextjs/src/server/authMiddleware.ts @@ -5,8 +5,7 @@ import { eventMethodCalled } from '@clerk/shared/telemetry'; import type { NextFetchEvent, NextMiddleware, NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; -import { isRedirect, mergeResponses, setHeader, stringifyHeaders } from '../utils'; -import { appendDevBrowserOnCrossOrigin } from '../utils/appendDevBrowserOnCrossOrigin'; +import { appendDevBrowserOnCrossOrigin, isRedirect, mergeResponses, setHeader, stringifyHeaders } from '../utils'; import { withLogger } from '../utils/debugLogger'; import { clerkClient } from './clerkClient'; import { createAuthenticateRequestOptions } from './clerkMiddleware'; diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index c54c7aa986a..6231b13ed17 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -1,12 +1,13 @@ -import type { AuthenticateRequestOptions, ClerkRequest } from '@clerk/backend/internal'; -import { AuthStatus, constants, createClerkRequest } from '@clerk/backend/internal'; +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, setHeader } from '../utils'; -import { appendDevBrowserOnCrossOrigin } from '../utils/appendDevBrowserOnCrossOrigin'; +import { appendDevBrowserOnCrossOrigin, isRedirect, 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, @@ -15,13 +16,17 @@ import { setRequestHeadersOnNextResponse, } from './utils'; -type ClerkMiddlewareAuthObject = { - protect: () => any; - redirectToSignIn: () => any; +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, + auth: () => ClerkMiddlewareAuthObject, request: NextMiddlewareRequestParam, event: NextMiddlewareEvtParam, ) => NextMiddlewareReturn; @@ -66,8 +71,29 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]): any => { throw new Error('Clerk: handshake status without redirect'); } - const authObj = requestState.toAuth(); - const handlerResult = (await handler?.(authObj, clerkRequest, event)) || NextResponse.next(); + const authObject = requestState.toAuth(); + + const authObjWithMethods: ClerkMiddlewareAuthObject = Object.assign(authObject, { + protect: createMiddlewareProtect(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 bonus URL to force not found error + return NextResponse.rewrite(`${clerkRequest.clerkUrl.origin}/clerk_${Date.now()}`); + case PROTECT_REDIRECT_TO_URL: + return NextResponse.redirect(e.redirectUrl); + case PROTECT_REDIRECT_TO_SIGN_IN: + return authObjWithMethods.redirectToSignIn(); + default: + throw e; + } + } if (isRedirect(handlerResult)) { const res = setHeader(handlerResult, constants.Headers.AuthReason, 'redirect'); @@ -121,3 +147,41 @@ export const createAuthenticateRequestOptions = (clerkRequest: ClerkRequest, opt ...handleMultiDomainAndProxy(clerkRequest, options), }; }; + +const createMiddlewareRedirectToSignIn = ( + clerkRequest: ClerkRequest, + requestState: RequestState, +): ClerkMiddlewareAuthObject['redirectToSignIn'] => { + return (opts = {}) => { + return redirect({ + redirectAdapter: url => { + const res = NextResponse.redirect(url); + return setHeader(res, constants.Headers.ClerkRedirectTo, 'true'); + }, + signInUrl: requestState.signInUrl, + signUpUrl: requestState.signUpUrl, + publishableKey: PUBLISHABLE_KEY, + }).redirectToSignIn({ returnBackUrl: opts.returnBackUrl === null ? '' : clerkRequest.clerkUrl.toString() }); + }; +}; + +const createMiddlewareProtect = (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 handleUnauthenticated = () => { + 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({ redirect, notFound, authObject, handleUnauthenticated })(params, options); + }) as AuthProtect; +}; diff --git a/packages/nextjs/src/app-router/server/protect.ts b/packages/nextjs/src/server/protect.ts similarity index 62% rename from packages/nextjs/src/app-router/server/protect.ts rename to packages/nextjs/src/server/protect.ts index 995a9b088ce..8eb76c67984 100644 --- a/packages/nextjs/src/app-router/server/protect.ts +++ b/packages/nextjs/src/server/protect.ts @@ -3,7 +3,6 @@ import type { CheckAuthorizationParamsWithCustomPermissions, CheckAuthorizationWithCustomPermissions, } from '@clerk/types'; -import { notFound, redirect } from 'next/navigation'; type AuthProtectOptions = { redirectUrl?: string }; @@ -21,8 +20,28 @@ export interface AuthProtect { (options?: AuthProtectOptions): SignedInAuthObject; } -export const createProtect = (authObj: AuthObject): AuthProtect => { - return (...args: any[]) => { +export const createProtect = (opts: { + 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 + */ + handleUnauthenticated?: () => void; +}): AuthProtect => { + const { handleUnauthenticated, authObject, redirect, notFound } = opts; + + return ((...args: any[]) => { const paramsOrFunction = args[0]?.redirectUrl ? undefined : (args[0] as @@ -30,7 +49,7 @@ export const createProtect = (authObj: AuthObject): AuthProtect => { | ((has: CheckAuthorizationWithCustomPermissions) => boolean)); const redirectUrl = (args[0]?.redirectUrl || args[1]?.redirectUrl) as string | undefined; - const handleUnauthorized = (): never => { + const handleUnauthorized = () => { if (redirectUrl) { redirect(redirectUrl); } @@ -40,23 +59,23 @@ export const createProtect = (authObj: AuthObject): AuthProtect => { /** * User is not authenticated */ - if (!authObj.userId) { - return handleUnauthorized(); + if (!authObject.userId) { + return handleUnauthenticated ? handleUnauthenticated() : handleUnauthorized(); } /** * User is authenticated */ if (!paramsOrFunction) { - return authObj; + return authObject; } /** * if a function is passed and returns false then throw not found */ if (typeof paramsOrFunction === 'function') { - if (paramsOrFunction(authObj.has)) { - return authObj; + if (paramsOrFunction(authObject.has)) { + return authObject; } return handleUnauthorized(); } @@ -64,10 +83,10 @@ export const createProtect = (authObj: AuthObject): AuthProtect => { /** * Checking if user is authorized when permission or role is passed */ - if (authObj.has(paramsOrFunction)) { - return authObj; + if (authObject.has(paramsOrFunction)) { + return authObject; } return handleUnauthorized(); - }; + }) as AuthProtect; }; diff --git a/packages/nextjs/src/utils/index.ts b/packages/nextjs/src/utils/index.ts index e4315a8fc20..304a319cb91 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 './appendDevBrowserOnCrossOrigin';