diff --git a/.changeset/thirty-fireants-cough.md b/.changeset/thirty-fireants-cough.md new file mode 100644 index 0000000000..6231c1ccdb --- /dev/null +++ b/.changeset/thirty-fireants-cough.md @@ -0,0 +1,5 @@ +--- +'@clerk/nextjs': patch +--- + +Bug fix: Avoid infinite redirect loop on Keyless mode by detecting if `clerkMiddleware()` is used in the application. diff --git a/packages/nextjs/src/app-router/keyless-actions.ts b/packages/nextjs/src/app-router/keyless-actions.ts index ad2d43ef64..78db3de863 100644 --- a/packages/nextjs/src/app-router/keyless-actions.ts +++ b/packages/nextjs/src/app-router/keyless-actions.ts @@ -1,22 +1,33 @@ 'use server'; import type { AccountlessApplication } from '@clerk/backend'; -import { cookies } from 'next/headers'; +import { cookies, headers } from 'next/headers'; import { redirect, RedirectType } from 'next/navigation'; +import { detectClerkMiddleware } from '../server/headers-utils'; import { getKeylessCookieName } from '../server/keyless'; import { canUseKeyless__server } from '../utils/feature-flags'; export async function syncKeylessConfigAction(args: AccountlessApplication & { returnUrl: string }): Promise { const { claimUrl, publishableKey, secretKey, returnUrl } = args; - void (await cookies()).set(getKeylessCookieName(), JSON.stringify({ claimUrl, publishableKey, secretKey }), { + const cookieStore = await cookies(); + cookieStore.set(getKeylessCookieName(), JSON.stringify({ claimUrl, publishableKey, secretKey }), { secure: true, httpOnly: true, }); - /** - * Force middleware to execute to read the new keys from the cookies and populate the authentication state correctly. - */ - redirect(`/clerk-sync-keyless?returnUrl=${returnUrl}`, RedirectType.replace); + const request = new Request('https://placeholder.com', { headers: await headers() }); + + // We cannot import `NextRequest` due to a bundling issue with server actions in Next.js 13. + // @ts-expect-error Request will work as well + if (detectClerkMiddleware(request)) { + /** + * Force middleware to execute to read the new keys from the cookies and populate the authentication state correctly. + */ + redirect(`/clerk-sync-keyless?returnUrl=${returnUrl}`, RedirectType.replace); + return; + } + + return; } export async function createOrReadKeylessAction(): Promise> { diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts index 8079e6a8bc..403dbf2710 100644 --- a/packages/nextjs/src/app-router/server/auth.ts +++ b/packages/nextjs/src/app-router/server/auth.ts @@ -5,9 +5,10 @@ import { notFound, redirect } from 'next/navigation'; import { PUBLISHABLE_KEY, SIGN_IN_URL, SIGN_UP_URL } from '../../server/constants'; import { createGetAuth } from '../../server/createGetAuth'; import { authAuthHeaderMissing } from '../../server/errors'; +import { getAuthKeyFromRequest, getHeader } from '../../server/headers-utils'; import type { AuthProtect } from '../../server/protect'; import { createProtect } from '../../server/protect'; -import { decryptClerkRequestData, getAuthKeyFromRequest, getHeader } from '../../server/utils'; +import { decryptClerkRequestData } from '../../server/utils'; import { buildRequestLike } from './utils'; type Auth = AuthObject & { redirectToSignIn: RedirectFun> }; diff --git a/packages/nextjs/src/server/clerkClient.ts b/packages/nextjs/src/server/clerkClient.ts index c58a57a614..907d7db1e6 100644 --- a/packages/nextjs/src/server/clerkClient.ts +++ b/packages/nextjs/src/server/clerkClient.ts @@ -2,8 +2,9 @@ import { constants } from '@clerk/backend/internal'; import { buildRequestLike, isPrerenderingBailout } from '../app-router/server/utils'; import { createClerkClientWithOptions } from './createClerkClient'; +import { getHeader } from './headers-utils'; import { clerkMiddlewareRequestDataStorage } from './middleware-storage'; -import { decryptClerkRequestData, getHeader } from './utils'; +import { decryptClerkRequestData } from './utils'; /** * Constructs a BAPI client that accesses request data within the runtime. diff --git a/packages/nextjs/src/server/createGetAuth.ts b/packages/nextjs/src/server/createGetAuth.ts index 03c1b7bd35..1f6106faeb 100644 --- a/packages/nextjs/src/server/createGetAuth.ts +++ b/packages/nextjs/src/server/createGetAuth.ts @@ -6,8 +6,9 @@ import { isTruthy } from '@clerk/shared/underscore'; import { withLogger } from '../utils/debugLogger'; import { getAuthDataFromRequest } from './data/getAuthDataFromRequest'; import { getAuthAuthHeaderMissing } from './errors'; +import { getHeader } from './headers-utils'; import type { RequestLike } from './types'; -import { assertAuthStatus, getCookie, getHeader } from './utils'; +import { assertAuthStatus, getCookie } from './utils'; export const createGetAuth = ({ noAuthStatusMessage, diff --git a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts index c276a29fc5..c67ca14f59 100644 --- a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts +++ b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts @@ -4,8 +4,9 @@ import { decodeJwt } from '@clerk/backend/jwt'; import type { LoggerNoCommit } from '../../utils/debugLogger'; import { API_URL, API_VERSION, PUBLISHABLE_KEY, SECRET_KEY } from '../constants'; +import { getAuthKeyFromRequest, getHeader } from '../headers-utils'; import type { RequestLike } from '../types'; -import { assertTokenSignature, decryptClerkRequestData, getAuthKeyFromRequest, getHeader } from '../utils'; +import { assertTokenSignature, decryptClerkRequestData } from '../utils'; /** * Given a request object, builds an auth object from the request data. Used in server-side environments to get access diff --git a/packages/nextjs/src/server/headers-utils.ts b/packages/nextjs/src/server/headers-utils.ts new file mode 100644 index 0000000000..9aa4f98f34 --- /dev/null +++ b/packages/nextjs/src/server/headers-utils.ts @@ -0,0 +1,53 @@ +import { constants } from '@clerk/backend/internal'; +import type { NextRequest } from 'next/server'; + +import type { RequestLike } from './types'; + +export function getCustomAttributeFromRequest(req: RequestLike, key: string): string | null | undefined { + // @ts-expect-error - TS doesn't like indexing into RequestLike + return key in req ? req[key] : undefined; +} + +export function getAuthKeyFromRequest( + req: RequestLike, + key: keyof typeof constants.Attributes, +): string | null | undefined { + return getCustomAttributeFromRequest(req, constants.Attributes[key]) || getHeader(req, constants.Headers[key]); +} + +export function getHeader(req: RequestLike, name: string): string | null | undefined { + if (isNextRequest(req) || isRequestWebAPI(req)) { + return req.headers.get(name); + } + + // If no header has been determined for IncomingMessage case, check if available within private `socket` headers + // When deployed to vercel, req.headers for API routes is a `IncomingHttpHeaders` key-val object which does not follow + // the Headers spec so the name is no longer case-insensitive. + return req.headers[name] || req.headers[name.toLowerCase()] || (req.socket as any)?._httpMessage?.getHeader(name); +} + +export function detectClerkMiddleware(req: RequestLike): boolean { + return Boolean(getAuthKeyFromRequest(req, 'AuthStatus')); +} + +export function isNextRequest(val: unknown): val is NextRequest { + try { + const { headers, nextUrl, cookies } = (val || {}) as NextRequest; + return ( + typeof headers?.get === 'function' && + typeof nextUrl?.searchParams.get === 'function' && + typeof cookies?.get === 'function' + ); + } catch (e) { + return false; + } +} + +export function isRequestWebAPI(val: unknown): val is Request { + try { + const { headers } = (val || {}) as Request; + return typeof headers?.get === 'function'; + } catch (e) { + return false; + } +} diff --git a/packages/nextjs/src/server/utils.ts b/packages/nextjs/src/server/utils.ts index aee72986bb..9903840155 100644 --- a/packages/nextjs/src/server/utils.ts +++ b/packages/nextjs/src/server/utils.ts @@ -21,31 +21,9 @@ import { missingSignInUrlInDev, } from './errors'; import { errorThrower } from './errorThrower'; +import { detectClerkMiddleware, isNextRequest } from './headers-utils'; import type { RequestLike } from './types'; -export function getCustomAttributeFromRequest(req: RequestLike, key: string): string | null | undefined { - // @ts-expect-error - TS doesn't like indexing into RequestLike - return key in req ? req[key] : undefined; -} - -export function getAuthKeyFromRequest( - req: RequestLike, - key: keyof typeof constants.Attributes, -): string | null | undefined { - return getCustomAttributeFromRequest(req, constants.Attributes[key]) || getHeader(req, constants.Headers[key]); -} - -export function getHeader(req: RequestLike, name: string): string | null | undefined { - if (isNextRequest(req)) { - return req.headers.get(name); - } - - // If no header has been determined for IncomingMessage case, check if available within private `socket` headers - // When deployed to vercel, req.headers for API routes is a `IncomingHttpHeaders` key-val object which does not follow - // the Headers spec so the name is no longer case-insensitive. - return req.headers[name] || req.headers[name.toLowerCase()] || (req.socket as any)?._httpMessage?.getHeader(name); -} - export function getCookie(req: RequestLike, name: string): string | undefined { if (isNextRequest(req)) { // Nextjs broke semver in the 13.0.0 -> 13.0.1 release, so even though @@ -61,19 +39,6 @@ export function getCookie(req: RequestLike, name: string): string | undefined { return req.cookies[name]; } -function isNextRequest(val: unknown): val is NextRequest { - try { - const { headers, nextUrl, cookies } = (val || {}) as NextRequest; - return ( - typeof headers?.get === 'function' && - typeof nextUrl?.searchParams.get === 'function' && - typeof cookies?.get === 'function' - ); - } catch (e) { - return false; - } -} - const OVERRIDE_HEADERS = 'x-middleware-override-headers'; const MIDDLEWARE_HEADER_PREFIX = 'x-middleware-request' as string; @@ -198,9 +163,7 @@ export const redirectAdapter = (url: string | URL) => { }; export function assertAuthStatus(req: RequestLike, error: string) { - const authStatus = getAuthKeyFromRequest(req, 'AuthStatus'); - - if (!authStatus) { + if (!detectClerkMiddleware(req)) { throw new Error(error); } }