Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/gorgeous-suits-rush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/nextjs": major
---

Stop `<ClerkProvider>` from opting applications into dynamic rendering. A new prop, `<ClerkProvider dynamic>` can be used to opt-in to dynamic rendering and make auth data available during server-side rendering. The RSC `auth()` helper should be preferred for accessing auth data during dynamic rendering.
5 changes: 5 additions & 0 deletions .changeset/two-bottles-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/clerk-react": minor
---

Internal changes to support `<ClerkProvider dynamic>`
2 changes: 0 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion packages/nextjs/src/app-router/client/ClerkProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation';
import React, { useEffect, useTransition } from 'react';

import { useSafeLayoutEffect } from '../../client-boundary/hooks/useSafeLayoutEffect';
import { ClerkNextOptionsProvider } from '../../client-boundary/NextOptionsContext';
import { ClerkNextOptionsProvider, useClerkNextOptions } from '../../client-boundary/NextOptionsContext';
import type { NextClerkProviderProps } from '../../types';
import { ClerkJSScript } from '../../utils/clerk-js-script';
import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv';
Expand Down Expand Up @@ -62,6 +62,12 @@ export const ClientClerkProvider = (props: NextClerkProviderProps) => {
const replace = useAwaitableReplace();
const [isPending, startTransition] = useTransition();

// Avoid rendering nested ClerkProviders by checking for the existence of the ClerkNextOptions context provider
const isNested = Boolean(useClerkNextOptions());
if (isNested) {
return props.children;
}

useEffect(() => {
if (!isPending) {
window.__clerk_internal_invalidateCachePromise?.();
Expand Down
45 changes: 37 additions & 8 deletions packages/nextjs/src/app-router/server/ClerkProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,56 @@
import type { AuthObject } from '@clerk/backend';
import type { InitialState, Without } from '@clerk/types';
import { headers } from 'next/headers';
import React from 'react';

import { PromisifiedAuthProvider } from '../../client-boundary/PromisifiedAuthProvider';
import { getDynamicAuthData } from '../../server/buildClerkProps';
import type { NextClerkProviderProps } from '../../types';
import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv';
import { ClientClerkProvider } from '../client/ClerkProvider';
import { initialState } from './auth';
import { getScriptNonceFromHeader } from './utils';
import { buildRequestLike, getScriptNonceFromHeader } from './utils';

const getDynamicClerkState = React.cache(async function getDynamicClerkState() {
const request = await buildRequestLike();
const data = getDynamicAuthData(request);

return data;
});

const getNonceFromCSPHeader = React.cache(async function getNonceFromCSPHeader() {
return getScriptNonceFromHeader((await headers()).get('Content-Security-Policy') || '') || '';
});

export async function ClerkProvider(
props: Without<NextClerkProviderProps, '__unstable_invokeMiddlewareOnAuthStateChange'>,
) {
const { children, ...rest } = props;
const state = (await initialState())?.__clerk_ssr_state as InitialState;
const cspHeader = (await headers()).get('Content-Security-Policy');
const { children, dynamic, ...rest } = props;
let statePromise: Promise<null | AuthObject> = Promise.resolve(null);
let nonce = Promise.resolve('');

return (
if (dynamic) {
statePromise = getDynamicClerkState();
nonce = getNonceFromCSPHeader();
}

const output = (
<ClientClerkProvider
{...mergeNextClerkPropsWithEnv(rest)}
nonce={getScriptNonceFromHeader(cspHeader || '')}
initialState={state}
nonce={await nonce}
initialState={await statePromise}
>
{children}
</ClientClerkProvider>
);

if (dynamic) {
return (
// TODO: fix types so AuthObject is compatible with InitialState
<PromisifiedAuthProvider authPromise={statePromise as unknown as Promise<InitialState>}>
{output}
</PromisifiedAuthProvider>
);
}

return output;
}
5 changes: 0 additions & 5 deletions packages/nextjs/src/app-router/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import type { AuthObject } from '@clerk/backend';
import { constants, createClerkRequest, createRedirect, type RedirectFun } from '@clerk/backend/internal';
import { notFound, redirect } from 'next/navigation';

import { buildClerkProps } from '../../server/buildClerkProps';
import { PUBLISHABLE_KEY, SIGN_IN_URL, SIGN_UP_URL } from '../../server/constants';
import { createGetAuth } from '../../server/createGetAuth';
import { authAuthHeaderMissing } from '../../server/errors';
Expand Down Expand Up @@ -62,7 +61,3 @@ auth.protect = async (...args: any[]) => {
});
return protect(...args);
};

export async function initialState() {
return buildClerkProps(await buildRequestLike());
}
2 changes: 1 addition & 1 deletion packages/nextjs/src/client-boundary/NextOptionsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ ClerkNextOptionsCtx.displayName = 'ClerkNextOptionsCtx';

const useClerkNextOptions = () => {
const ctx = React.useContext(ClerkNextOptionsCtx) as { value: ClerkNextContextValue };
return ctx.value;
return ctx?.value;
};

const ClerkNextOptionsProvider = (
Expand Down
85 changes: 85 additions & 0 deletions packages/nextjs/src/client-boundary/PromisifiedAuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
'use client';

import { useAuth } from '@clerk/clerk-react';
import { useDerivedAuth } from '@clerk/clerk-react/internal';
import type { InitialState } from '@clerk/types';
import { useRouter } from 'next/compat/router';
import { PHASE_PRODUCTION_BUILD } from 'next/constants';
import React from 'react';

const PromisifiedAuthContext = React.createContext<Promise<InitialState> | InitialState | null>(null);

export function PromisifiedAuthProvider({
authPromise,
children,
}: {
authPromise: Promise<InitialState> | InitialState;
children: React.ReactNode;
}) {
return <PromisifiedAuthContext.Provider value={authPromise}>{children}</PromisifiedAuthContext.Provider>;
}

/**
* Returns the current auth state, the user and session ids and the `getToken`
* that can be used to retrieve the given template or the default Clerk token.
*
* Until Clerk loads, `isLoaded` will be set to `false`.
* Once Clerk loads, `isLoaded` will be set to `true`, and you can
* safely access the `userId` and `sessionId` variables.
*
* For projects using NextJs or Remix, you can have immediate access to this data during SSR
* simply by using the `ClerkProvider`.
*
* @example
* A simple example:
*
* import { useAuth } from '@clerk/nextjs'
*
* function Hello() {
* const { isSignedIn, sessionId, userId } = useAuth();
* if(isSignedIn) {
* return null;
* }
* console.log(sessionId, userId)
* return <div>...</div>
* }
*
* @example
* Basic example in a NextJs app. This page will be fully rendered during SSR:
*
* import { useAuth } from '@clerk/nextjs'
*
* export HelloPage = () => {
* const { isSignedIn, sessionId, userId } = useAuth();
* console.log(isSignedIn, sessionId, userId)
* return <div>...</div>
* }
*/
export function usePromisifiedAuth() {
const isPagesRouter = !useRouter();
const valueFromContext = React.useContext(PromisifiedAuthContext);

let resolvedData = valueFromContext;
if (valueFromContext && 'then' in valueFromContext) {
resolvedData = React.use(valueFromContext);
}

// At this point we should have a usable auth object

if (typeof window === 'undefined') {
// Pages router should always use useAuth as it is able to grab initial auth state from context during SSR.
if (isPagesRouter) {
return useAuth();
}

if (!resolvedData && process.env.NEXT_PHASE !== PHASE_PRODUCTION_BUILD) {
throw new Error(
'Clerk: useAuth() called in static mode, wrap this component in <ClerkProvider dynamic> to make auth data available during server-side rendering.',
);
}
// We don't need to deal with Clerk being loaded here
return useDerivedAuth(resolvedData);
} else {
return useAuth(resolvedData);
}
}
3 changes: 2 additions & 1 deletion packages/nextjs/src/client-boundary/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
'use client';

export {
useAuth,
useClerk,
useEmailLink,
useOrganization,
Expand All @@ -20,3 +19,5 @@ export {
isMetamaskError,
EmailLinkErrorCode,
} from '@clerk/clerk-react/errors';

export { usePromisifiedAuth as useAuth } from './PromisifiedAuthProvider';
55 changes: 15 additions & 40 deletions packages/nextjs/src/server/buildClerkProps.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,8 @@
import type { Organization, Session, User } from '@clerk/backend';
import {
AuthStatus,
constants,
makeAuthObjectSerializable,
signedInAuthObject,
signedOutAuthObject,
stripPrivateDataFromObject,
} from '@clerk/backend/internal';
import { decodeJwt } from '@clerk/backend/jwt';
import type { AuthObject, Organization, Session, User } from '@clerk/backend';
import { makeAuthObjectSerializable, stripPrivateDataFromObject } from '@clerk/backend/internal';

import { API_URL, API_VERSION, SECRET_KEY } from './constants';
import { getAuthDataFromRequest } from './data/getAuthDataFromRequest';
import type { RequestLike } from './types';
import { decryptClerkRequestData, getAuthKeyFromRequest, getHeader, injectSSRStateIntoObject } from './utils';

type BuildClerkPropsInitState = { user?: User | null; session?: Session | null; organization?: Organization | null };

Expand All @@ -33,34 +24,18 @@ type BuildClerkPropsInitState = { user?: User | null; session?: Session | null;
*/
type BuildClerkProps = (req: RequestLike, authState?: BuildClerkPropsInitState) => Record<string, unknown>;

export const buildClerkProps: BuildClerkProps = (req, initState = {}) => {
const authStatus = getAuthKeyFromRequest(req, 'AuthStatus');
const authToken = getAuthKeyFromRequest(req, 'AuthToken');
const authMessage = getAuthKeyFromRequest(req, 'AuthMessage');
const authReason = getAuthKeyFromRequest(req, 'AuthReason');
export const buildClerkProps: BuildClerkProps = (req, initialState = {}) => {
const sanitizedAuthObject = getDynamicAuthData(req, initialState);

const encryptedRequestData = getHeader(req, constants.Headers.ClerkRequestData);
const decryptedRequestData = decryptClerkRequestData(encryptedRequestData);

const options = {
secretKey: decryptedRequestData.secretKey || SECRET_KEY,
apiUrl: API_URL,
apiVersion: API_VERSION,
authStatus,
authMessage,
authReason,
};

let authObject;
if (!authStatus || authStatus !== AuthStatus.SignedIn) {
authObject = signedOutAuthObject(options);
} else {
const jwt = decodeJwt(authToken as string);
// Serializing the state on dev env is a temp workaround for the following issue:
// https://github.com/vercel/next.js/discussions/11209|Next.js
const __clerk_ssr_state =
process.env.NODE_ENV !== 'production' ? JSON.parse(JSON.stringify(sanitizedAuthObject)) : sanitizedAuthObject;
return { __clerk_ssr_state };
};

// @ts-expect-error - TODO @nikos: Align types
authObject = signedInAuthObject(options, jwt.raw.text, jwt.payload);
}
export function getDynamicAuthData(req: RequestLike, initialState = {}) {
const authObject = getAuthDataFromRequest(req);

const sanitizedAuthObject = makeAuthObjectSerializable(stripPrivateDataFromObject({ ...authObject, ...initState }));
return injectSSRStateIntoObject({}, sanitizedAuthObject);
};
return makeAuthObjectSerializable(stripPrivateDataFromObject({ ...authObject, ...initialState })) as AuthObject;
}
50 changes: 7 additions & 43 deletions packages/nextjs/src/server/createGetAuth.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { AuthObject } from '@clerk/backend';
import { AuthStatus, constants, signedInAuthObject, signedOutAuthObject } from '@clerk/backend/internal';
import { constants } from '@clerk/backend/internal';
import { decodeJwt } from '@clerk/backend/jwt';
import { isTruthy } from '@clerk/shared';

import { withLogger } from '../utils/debugLogger';
import { API_URL, API_VERSION, SECRET_KEY } from './constants';
import { getAuthDataFromRequest } from './data/getAuthDataFromRequest';
import { getAuthAuthHeaderMissing } from './errors';
import type { RequestLike } from './types';
import { assertTokenSignature, decryptClerkRequestData, getAuthKeyFromRequest, getCookie, getHeader } from './utils';
import { assertAuthStatus, getCookie, getHeader } from './utils';

export const createGetAuth = ({
noAuthStatusMessage,
Expand All @@ -17,50 +18,13 @@ export const createGetAuth = ({
}) =>
withLogger(debugLoggerName, logger => {
return (req: RequestLike, opts?: { secretKey?: string }): AuthObject => {
if (getHeader(req, constants.Headers.EnableDebug) === 'true') {
if (isTruthy(getHeader(req, constants.Headers.EnableDebug))) {
logger.enable();
}

// When the auth status is set, we trust that the middleware has already run
// Then, we don't have to re-verify the JWT here,
// we can just strip out the claims manually.
const authToken = getAuthKeyFromRequest(req, 'AuthToken');
const authSignature = getAuthKeyFromRequest(req, 'AuthSignature');
const authMessage = getAuthKeyFromRequest(req, 'AuthMessage');
const authReason = getAuthKeyFromRequest(req, 'AuthReason');
const authStatus = getAuthKeyFromRequest(req, 'AuthStatus') as AuthStatus;
logger.debug('Headers debug', { authStatus, authMessage, authReason });
assertAuthStatus(req, noAuthStatusMessage);

if (!authStatus) {
throw new Error(noAuthStatusMessage);
}

const encryptedRequestData = getHeader(req, constants.Headers.ClerkRequestData);
const decryptedRequestData = decryptClerkRequestData(encryptedRequestData);

const options = {
authStatus,
apiUrl: API_URL,
apiVersion: API_VERSION,
authMessage,
secretKey: opts?.secretKey || decryptedRequestData.secretKey || SECRET_KEY,
authReason,
};

logger.debug('Options debug', options);

if (authStatus === AuthStatus.SignedIn) {
assertTokenSignature(authToken as string, options.secretKey, authSignature);

const jwt = decodeJwt(authToken as string);

logger.debug('JWT debug', jwt.raw.text);

// @ts-expect-error - TODO @nikos: Align types
return signedInAuthObject(options, jwt.raw.text, jwt.payload);
}

return signedOutAuthObject(options);
return getAuthDataFromRequest(req, { ...opts, logger });
};
});

Expand Down
Loading
Loading