Skip to content

Commit

Permalink
fix(nextjs): Detect middlware usage to avoid infinite loop on Keyless…
Browse files Browse the repository at this point in the history
… mode (#4879)
  • Loading branch information
panteliselef authored Jan 13, 2025
1 parent 72d2953 commit 2e505ca
Show file tree
Hide file tree
Showing 8 changed files with 85 additions and 49 deletions.
5 changes: 5 additions & 0 deletions .changeset/thirty-fireants-cough.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 17 additions & 6 deletions packages/nextjs/src/app-router/keyless-actions.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<null | Omit<AccountlessApplication, 'secretKey'>> {
Expand Down
3 changes: 2 additions & 1 deletion packages/nextjs/src/app-router/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof redirect>> };
Expand Down
3 changes: 2 additions & 1 deletion packages/nextjs/src/server/clerkClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion packages/nextjs/src/server/createGetAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion packages/nextjs/src/server/data/getAuthDataFromRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions packages/nextjs/src/server/headers-utils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
41 changes: 2 additions & 39 deletions packages/nextjs/src/server/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;

Expand Down Expand Up @@ -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);
}
}
Expand Down

0 comments on commit 2e505ca

Please sign in to comment.