From 3f640805d2a4e1616aafa56f6848d6657911bb99 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 5 Dec 2024 17:41:00 +0200 Subject: [PATCH] feat(nextjs): Support Keyless mode (#4602) --- .changeset/dry-cats-change.md | 7 + .changeset/grumpy-camels-think.md | 10 + .changeset/itchy-cats-drive.md | 11 ++ .changeset/warm-spiders-develop.md | 6 + integration/tests/next-build.test.ts | 6 +- .../endpoints/AccountlessApplicationsAPI.ts | 13 ++ packages/backend/src/api/endpoints/index.ts | 1 + packages/backend/src/api/factory.ts | 4 + packages/backend/src/api/request.ts | 17 +- .../api/resources/AccountlessApplication.ts | 13 ++ .../backend/src/api/resources/Deserializer.ts | 3 + packages/backend/src/api/resources/JSON.ts | 8 + packages/backend/src/api/resources/index.ts | 1 + packages/backend/src/index.ts | 2 + packages/clerk-js/jest.setup.ts | 2 +- packages/clerk-js/rspack.config.js | 7 +- packages/clerk-js/sandbox/app.ts | 2 +- packages/clerk-js/src/core/clerk.ts | 12 +- packages/clerk-js/src/globals.d.ts | 5 +- packages/clerk-js/src/ui/Components.tsx | 8 +- .../index.tsx | 10 +- .../clerk-js/src/ui/lazyModules/components.ts | 10 +- packages/nextjs/package.cjs.json | 4 + packages/nextjs/package.esm.json | 4 + .../src/app-router/client/ClerkProvider.tsx | 32 +++- .../app-router/client/keyless-cookie-sync.tsx | 23 +++ .../client/keyless-creator-reader.tsx | 25 +++ .../nextjs/src/app-router/keyless-actions.ts | 44 +++++ .../src/app-router/server/ClerkProvider.tsx | 37 +++- .../src/runtime/browser/safe-node-apis.js | 7 + .../nextjs/src/runtime/node/safe-node-apis.js | 15 ++ packages/nextjs/src/server/clerkMiddleware.ts | 66 ++++++- packages/nextjs/src/server/constants.ts | 2 + .../src/server/data/getAuthDataFromRequest.ts | 3 +- packages/nextjs/src/server/keyless-node.ts | 173 ++++++++++++++++++ packages/nextjs/src/server/keyless.ts | 49 +++++ packages/nextjs/src/server/utils.ts | 25 ++- packages/nextjs/src/utils/clerk-js-script.tsx | 10 +- packages/nextjs/src/utils/feature-flags.ts | 9 + packages/nextjs/src/utils/sdk-versions.ts | 11 ++ packages/nextjs/tsup.config.ts | 16 +- .../src/contexts/ClerkContextProvider.tsx | 1 + packages/react/src/contexts/ClerkProvider.tsx | 4 +- packages/react/src/isomorphicClerk.ts | 12 +- packages/react/src/types.ts | 5 + packages/types/src/clerk.ts | 2 +- 46 files changed, 673 insertions(+), 64 deletions(-) create mode 100644 .changeset/dry-cats-change.md create mode 100644 .changeset/grumpy-camels-think.md create mode 100644 .changeset/itchy-cats-drive.md create mode 100644 .changeset/warm-spiders-develop.md create mode 100644 packages/backend/src/api/endpoints/AccountlessApplicationsAPI.ts create mode 100644 packages/backend/src/api/resources/AccountlessApplication.ts rename packages/clerk-js/src/ui/components/{AccountlessPrompt => KeylessPrompt}/index.tsx (95%) create mode 100644 packages/nextjs/src/app-router/client/keyless-cookie-sync.tsx create mode 100644 packages/nextjs/src/app-router/client/keyless-creator-reader.tsx create mode 100644 packages/nextjs/src/app-router/keyless-actions.ts create mode 100644 packages/nextjs/src/runtime/browser/safe-node-apis.js create mode 100644 packages/nextjs/src/runtime/node/safe-node-apis.js create mode 100644 packages/nextjs/src/server/keyless-node.ts create mode 100644 packages/nextjs/src/server/keyless.ts create mode 100644 packages/nextjs/src/utils/feature-flags.ts create mode 100644 packages/nextjs/src/utils/sdk-versions.ts diff --git a/.changeset/dry-cats-change.md b/.changeset/dry-cats-change.md new file mode 100644 index 0000000000..bf026a9f71 --- /dev/null +++ b/.changeset/dry-cats-change.md @@ -0,0 +1,7 @@ +--- +'@clerk/backend': minor +--- + +New **experimental** API: `AccountlessApplicationAPI` + +Inside `clerkClient` you can activate this new API through `__experimental_accountlessApplications`. It allows you to generate an "accountless" application and the API returns the publishable key, secret key, and an URL as a response. The URL allows a user to claim this application with their account. Hence the name "accountless" because in its initial state the application is not attached to any account yet. diff --git a/.changeset/grumpy-camels-think.md b/.changeset/grumpy-camels-think.md new file mode 100644 index 0000000000..0d7b621d64 --- /dev/null +++ b/.changeset/grumpy-camels-think.md @@ -0,0 +1,10 @@ +--- +'@clerk/clerk-react': minor +--- + +Various internal changes have been made to support a new feature called "Keyless mode". You'll be able to use this feature with Next.js and `@clerk/nextjs` initially. Read the `@clerk/nextjs` changelog to learn more. + +List of changes: +- A new internal prop called `__internal_bypassMissingPublishableKey` has been added. Normally an error is thrown when the publishable key is missing, this disables this behavior. +- Loading of `clerk-js` won't be attempted when a missing key is present +- A new instance of `IsomorphicClerk` (an internal Clerk class) is created for each new publishable key diff --git a/.changeset/itchy-cats-drive.md b/.changeset/itchy-cats-drive.md new file mode 100644 index 0000000000..7e4f301c0b --- /dev/null +++ b/.changeset/itchy-cats-drive.md @@ -0,0 +1,11 @@ +--- +'@clerk/nextjs': minor +--- + +A new **experimental** feature is available: "Keyless mode" + +Normally, in order to start a Clerk + Next.js application you need to provide a publishable key and secret key. With "Keyless mode" activated you no longer need to provide these two keys to start your Clerk application. These keys will be automatically generated and the application can be claimed with your account either through a UI prompt or with a URL in your terminal. + +**Requirements**: +- You need to use Next.js `14.2.0` or later +- You need to set the environment variable `NEXT_PUBLIC_CLERK_ENABLE_KEYLESS=true` diff --git a/.changeset/warm-spiders-develop.md b/.changeset/warm-spiders-develop.md new file mode 100644 index 0000000000..e91b4ac51b --- /dev/null +++ b/.changeset/warm-spiders-develop.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/types': minor +--- + +Replace `__internal_claimAccountlessKeysUrl` with `__internal_claimKeylessApplicationUrl`. diff --git a/integration/tests/next-build.test.ts b/integration/tests/next-build.test.ts index 9a6ce801c4..d22e13096c 100644 --- a/integration/tests/next-build.test.ts +++ b/integration/tests/next-build.test.ts @@ -132,7 +132,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) 'src/app/nested-provider/page.tsx', () => `import { ClerkProvider } from '@clerk/nextjs'; import { ClientComponent } from './client'; - + export default function Page() { return ( @@ -147,10 +147,10 @@ export default function RootLayout({ children }: { children: React.ReactNode }) () => `'use client'; import { useAuth } from '@clerk/nextjs'; - + export function ClientComponent() { useAuth(); - + return

I am dynamically rendered

; } `, diff --git a/packages/backend/src/api/endpoints/AccountlessApplicationsAPI.ts b/packages/backend/src/api/endpoints/AccountlessApplicationsAPI.ts new file mode 100644 index 0000000000..784ce061f9 --- /dev/null +++ b/packages/backend/src/api/endpoints/AccountlessApplicationsAPI.ts @@ -0,0 +1,13 @@ +import type { AccountlessApplication } from '../resources/AccountlessApplication'; +import { AbstractAPI } from './AbstractApi'; + +const basePath = '/accountless_applications'; + +export class AccountlessApplicationAPI extends AbstractAPI { + public async createAccountlessApplication() { + return this.request({ + method: 'POST', + path: basePath, + }); + } +} diff --git a/packages/backend/src/api/endpoints/index.ts b/packages/backend/src/api/endpoints/index.ts index 734c789a5b..f1ea10ac9c 100644 --- a/packages/backend/src/api/endpoints/index.ts +++ b/packages/backend/src/api/endpoints/index.ts @@ -1,3 +1,4 @@ +export * from './AccountlessApplicationsAPI'; export * from './AbstractApi'; export * from './AllowlistIdentifierApi'; export * from './ClientApi'; diff --git a/packages/backend/src/api/factory.ts b/packages/backend/src/api/factory.ts index 64847e7081..47098188db 100644 --- a/packages/backend/src/api/factory.ts +++ b/packages/backend/src/api/factory.ts @@ -1,4 +1,5 @@ import { + AccountlessApplicationAPI, AllowlistIdentifierAPI, ClientAPI, DomainAPI, @@ -23,6 +24,9 @@ export function createBackendApiClient(options: CreateBackendApiOptions) { const request = buildRequest(options); return { + __experimental_accountlessApplications: new AccountlessApplicationAPI( + buildRequest({ ...options, requireSecretKey: false }), + ), allowlistIdentifiers: new AllowlistIdentifierAPI(request), clients: new ClientAPI(request), emailAddresses: new EmailAddressAPI(request), diff --git a/packages/backend/src/api/request.ts b/packages/backend/src/api/request.ts index df1d48b587..da41cdb2c2 100644 --- a/packages/backend/src/api/request.ts +++ b/packages/backend/src/api/request.ts @@ -51,13 +51,26 @@ type BuildRequestOptions = { apiVersion?: string; /* Library/SDK name */ userAgent?: string; + /** + * Allow requests without specifying a secret key. In most cases this should be set to `false`. + * Defaults to `true`. + */ + requireSecretKey?: boolean; }; export function buildRequest(options: BuildRequestOptions) { const requestFn = async (requestOptions: ClerkBackendApiRequestOptions): Promise> => { - const { secretKey, apiUrl = API_URL, apiVersion = API_VERSION, userAgent = USER_AGENT } = options; + const { + secretKey, + requireSecretKey = true, + apiUrl = API_URL, + apiVersion = API_VERSION, + userAgent = USER_AGENT, + } = options; const { path, method, queryParams, headerParams, bodyParams, formData } = requestOptions; - assertValidSecretKey(secretKey); + if (requireSecretKey) { + assertValidSecretKey(secretKey); + } const url = joinPaths(apiUrl, apiVersion, path); diff --git a/packages/backend/src/api/resources/AccountlessApplication.ts b/packages/backend/src/api/resources/AccountlessApplication.ts new file mode 100644 index 0000000000..e4fe3dc46c --- /dev/null +++ b/packages/backend/src/api/resources/AccountlessApplication.ts @@ -0,0 +1,13 @@ +import type { AccountlessApplicationJSON } from './JSON'; + +export class AccountlessApplication { + constructor( + readonly publishableKey: string, + readonly secretKey: string, + readonly claimUrl: string, + ) {} + + static fromJSON(data: AccountlessApplicationJSON): AccountlessApplication { + return new AccountlessApplication(data.publishable_key, data.secret_key, data.claim_url); + } +} diff --git a/packages/backend/src/api/resources/Deserializer.ts b/packages/backend/src/api/resources/Deserializer.ts index 89dfd84005..11a6a36164 100644 --- a/packages/backend/src/api/resources/Deserializer.ts +++ b/packages/backend/src/api/resources/Deserializer.ts @@ -17,6 +17,7 @@ import { Token, User, } from '.'; +import { AccountlessApplication } from './AccountlessApplication'; import type { PaginatedResponseJSON } from './JSON'; import { ObjectType } from './JSON'; @@ -65,6 +66,8 @@ function jsonToObject(item: any): any { } switch (item.object) { + case ObjectType.AccountlessApplication: + return AccountlessApplication.fromJSON(item); case ObjectType.AllowlistIdentifier: return AllowlistIdentifier.fromJSON(item); case ObjectType.Client: diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index a3aed38e3b..077bba40f7 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -7,6 +7,7 @@ import type { } from './Enums'; export const ObjectType = { + AccountlessApplication: 'accountless_application', AllowlistIdentifier: 'allowlist_identifier', Client: 'client', Email: 'email', @@ -48,6 +49,13 @@ export interface TokenJSON { jwt: string; } +export interface AccountlessApplicationJSON extends ClerkResourceJSON { + object: typeof ObjectType.AccountlessApplication; + publishable_key: string; + secret_key: string; + claim_url: string; +} + export interface AllowlistIdentifierJSON extends ClerkResourceJSON { object: typeof ObjectType.AllowlistIdentifier; identifier: string; diff --git a/packages/backend/src/api/resources/index.ts b/packages/backend/src/api/resources/index.ts index 9a0ec1a376..20854c5644 100644 --- a/packages/backend/src/api/resources/index.ts +++ b/packages/backend/src/api/resources/index.ts @@ -1,3 +1,4 @@ +export * from './AccountlessApplication'; export * from './AllowlistIdentifier'; export * from './Client'; export * from './DeletedObject'; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index e74b5ba103..2a1659cbf7 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -53,6 +53,7 @@ export type { VerifyTokenOptions } from './tokens/verify'; * JSON types */ export type { + AccountlessApplicationJSON, ClerkResourceJSON, TokenJSON, AllowlistIdentifierJSON, @@ -87,6 +88,7 @@ export type { * Resources */ export type { + AccountlessApplication, AllowlistIdentifier, Client, EmailAddress, diff --git a/packages/clerk-js/jest.setup.ts b/packages/clerk-js/jest.setup.ts index 38259c0787..f3b9169534 100644 --- a/packages/clerk-js/jest.setup.ts +++ b/packages/clerk-js/jest.setup.ts @@ -35,7 +35,7 @@ if (typeof window !== 'undefined') { global.__PKG_NAME__ = ''; global.__PKG_VERSION__ = ''; - global.__BUILD_FLAG_ACCOUNTLESS_UI__ = ''; + global.__BUILD_FLAG_KEYLESS_UI__ = ''; global.BUILD_ENABLE_NEW_COMPONENTS = ''; //@ts-expect-error diff --git a/packages/clerk-js/rspack.config.js b/packages/clerk-js/rspack.config.js index 28abc4eedf..75d8517854 100644 --- a/packages/clerk-js/rspack.config.js +++ b/packages/clerk-js/rspack.config.js @@ -42,11 +42,14 @@ const common = ({ mode, disableRHC = false }) => { }, plugins: [ new rspack.DefinePlugin({ - __BUILD_DISABLE_RHC__: JSON.stringify(disableRHC), __DEV__: isDevelopment(mode), - __BUILD_FLAG_ACCOUNTLESS_UI__: isDevelopment(mode), __PKG_VERSION__: JSON.stringify(packageJSON.version), __PKG_NAME__: JSON.stringify(packageJSON.name), + /** + * Build time feature flags. + */ + __BUILD_FLAG_KEYLESS_UI__: isDevelopment(mode), + __BUILD_DISABLE_RHC__: JSON.stringify(disableRHC), BUILD_ENABLE_NEW_COMPONENTS: JSON.stringify(process.env.BUILD_ENABLE_NEW_COMPONENTS), }), new rspack.EnvironmentPlugin({ diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index 606a2e0d4c..83479c5482 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -160,7 +160,7 @@ function addCurrentRouteIndicator(currentRoute: string) { Clerk.mountWaitlist(app, componentControls.waitlist.getProps() ?? {}); }, '/accountless': () => { - Clerk.__unstable__updateProps({ options: { __internal_claimAccountlessKeysUrl: '/test-url' } }); + Clerk.__unstable__updateProps({ options: { __internal_claimKeylessApplicationUrl: '/test-url' } }); }, '/open-sign-in': () => { mountOpenSignInButton(app, componentControls.signIn.getProps() ?? {}); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index b5e0b5b826..42c17325b8 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -1899,8 +1899,8 @@ export class Clerk implements ClerkInterface { void this.#captchaHeartbeat.start(); this.#clearClerkQueryParams(); this.#handleImpersonationFab(); - if (__BUILD_FLAG_ACCOUNTLESS_UI__) { - this.#handleAccountlessPrompt(); + if (__BUILD_FLAG_KEYLESS_UI__) { + this.#handleKeylessPrompt(); } return true; }; @@ -2031,12 +2031,12 @@ export class Clerk implements ClerkInterface { }); }; - #handleAccountlessPrompt = () => { - if (__BUILD_FLAG_ACCOUNTLESS_UI__) { + #handleKeylessPrompt = () => { + if (__BUILD_FLAG_KEYLESS_UI__) { void this.#componentControls?.ensureMounted().then(controls => { - if (this.#options.__internal_claimAccountlessKeysUrl) { + if (this.#options.__internal_claimKeylessApplicationUrl) { controls.updateProps({ - options: { __internal_claimAccountlessKeysUrl: this.#options.__internal_claimAccountlessKeysUrl }, + options: { __internal_claimKeylessApplicationUrl: this.#options.__internal_claimKeylessApplicationUrl }, }); } }); diff --git a/packages/clerk-js/src/globals.d.ts b/packages/clerk-js/src/globals.d.ts index a1ac10dabf..5bebbb0b62 100644 --- a/packages/clerk-js/src/globals.d.ts +++ b/packages/clerk-js/src/globals.d.ts @@ -1,8 +1,11 @@ declare global { const __DEV__: boolean; - const __BUILD_FLAG_ACCOUNTLESS_UI__: boolean; const __PKG_NAME__: string; const __PKG_VERSION__: string; + /** + * Build time feature flags. + */ + const __BUILD_FLAG_KEYLESS_UI__: boolean; const __BUILD_DISABLE_RHC__: string; interface Window { diff --git a/packages/clerk-js/src/ui/Components.tsx b/packages/clerk-js/src/ui/Components.tsx index 2118601a4c..b5afeb1c79 100644 --- a/packages/clerk-js/src/ui/Components.tsx +++ b/packages/clerk-js/src/ui/Components.tsx @@ -23,10 +23,10 @@ import type { AppearanceCascade } from './customizables/parseAppearance'; import { useClerkModalStateParams } from './hooks/useClerkModalStateParams'; import type { ClerkComponentName } from './lazyModules/components'; import { - AccountlessPrompt, BlankCaptchaModal, CreateOrganizationModal, ImpersonationFab, + KeylessPrompt, OrganizationProfileModal, preloadComponent, SignInModal, @@ -517,10 +517,10 @@ const Components = (props: ComponentsProps) => { )} - {__BUILD_FLAG_ACCOUNTLESS_UI__ - ? state.options?.__internal_claimAccountlessKeysUrl && ( + {__BUILD_FLAG_KEYLESS_UI__ + ? state.options?.__internal_claimKeylessApplicationUrl && ( - + ) : null} diff --git a/packages/clerk-js/src/ui/components/AccountlessPrompt/index.tsx b/packages/clerk-js/src/ui/components/KeylessPrompt/index.tsx similarity index 95% rename from packages/clerk-js/src/ui/components/AccountlessPrompt/index.tsx rename to packages/clerk-js/src/ui/components/KeylessPrompt/index.tsx index 413030fb60..3c391ab441 100644 --- a/packages/clerk-js/src/ui/components/AccountlessPrompt/index.tsx +++ b/packages/clerk-js/src/ui/components/KeylessPrompt/index.tsx @@ -6,7 +6,7 @@ import { Col, descriptors, Flex, Link, Text } from '../../customizables'; import { Portal } from '../../elements/Portal'; import { InternalThemeProvider, mqu } from '../../styledSystem'; -type AccountlessPromptProps = { +type KeylessPromptProps = { url?: string; }; @@ -50,7 +50,7 @@ const FabContent = ({ title, signOutText, url }: FabContentProps) => { ); }; -export const _AccountlessPrompt = (props: AccountlessPromptProps) => { +const _KeylessPrompt = (props: KeylessPromptProps) => { // const { parsedInternalTheme } = useAppearance(); const containerRef = useRef(null); @@ -150,7 +150,7 @@ export const _AccountlessPrompt = (props: AccountlessPromptProps) => { }, })} > - 🔓Accountless Mode + 🔓Keyless Mode ({ @@ -169,8 +169,8 @@ export const _AccountlessPrompt = (props: AccountlessPromptProps) => { ); }; -export const AccountlessPrompt = (props: AccountlessPromptProps) => ( +export const KeylessPrompt = (props: KeylessPromptProps) => ( - <_AccountlessPrompt {...props} /> + <_KeylessPrompt {...props} /> ); diff --git a/packages/clerk-js/src/ui/lazyModules/components.ts b/packages/clerk-js/src/ui/lazyModules/components.ts index 14c544bca9..f15ecb816a 100644 --- a/packages/clerk-js/src/ui/lazyModules/components.ts +++ b/packages/clerk-js/src/ui/lazyModules/components.ts @@ -16,8 +16,8 @@ const componentImportPaths = { BlankCaptchaModal: () => import(/* webpackChunkName: "blankcaptcha" */ './../components/BlankCaptchaModal'), UserVerification: () => import(/* webpackChunkName: "userverification" */ './../components/UserVerification'), Waitlist: () => import(/* webpackChunkName: "waitlist" */ './../components/Waitlist'), - AccountlessPrompt: __BUILD_FLAG_ACCOUNTLESS_UI__ - ? () => import(/* webpackChunkName: "accountlessPrompt" */ './../components/AccountlessPrompt') + KeylessPrompt: __BUILD_FLAG_KEYLESS_UI__ + ? () => import(/* webpackChunkName: "keylessPrompt" */ '../components/KeylessPrompt') : () => null, } as const; @@ -86,9 +86,9 @@ export const BlankCaptchaModal = lazy(() => export const ImpersonationFab = lazy(() => componentImportPaths.ImpersonationFab().then(module => ({ default: module.ImpersonationFab })), ); -export const AccountlessPrompt = __BUILD_FLAG_ACCOUNTLESS_UI__ - ? // @ts-expect-error Types are broken due to __BUILD_FLAG_ACCOUNTLESS_UI__ - lazy(() => componentImportPaths.AccountlessPrompt().then(module => ({ default: module.AccountlessPrompt }))) +export const KeylessPrompt = __BUILD_FLAG_KEYLESS_UI__ + ? // @ts-expect-error Types are broken due to __BUILD_FLAG_KEYLESS_UI__ + lazy(() => componentImportPaths.KeylessPrompt().then(module => ({ default: module.KeylessPrompt }))) : () => null; export const preloadComponent = async (component: unknown) => { diff --git a/packages/nextjs/package.cjs.json b/packages/nextjs/package.cjs.json index 2a6bfb3897..b94ce32857 100644 --- a/packages/nextjs/package.cjs.json +++ b/packages/nextjs/package.cjs.json @@ -4,6 +4,10 @@ "#components": { "react-server": "./components.server.js", "default": "./components.client.js" + }, + "#safe-node-apis": { + "node": "./runtime/node/safe-node-apis.js", + "default": "./runtime/browser/safe-node-apis.js" } } } diff --git a/packages/nextjs/package.esm.json b/packages/nextjs/package.esm.json index 2a6bfb3897..b94ce32857 100644 --- a/packages/nextjs/package.esm.json +++ b/packages/nextjs/package.esm.json @@ -4,6 +4,10 @@ "#components": { "react-server": "./components.server.js", "default": "./components.client.js" + }, + "#safe-node-apis": { + "node": "./runtime/node/safe-node-apis.js", + "default": "./runtime/browser/safe-node-apis.js" } } } diff --git a/packages/nextjs/src/app-router/client/ClerkProvider.tsx b/packages/nextjs/src/app-router/client/ClerkProvider.tsx index a583880aca..4325ef5143 100644 --- a/packages/nextjs/src/app-router/client/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/client/ClerkProvider.tsx @@ -2,6 +2,7 @@ import { ClerkProvider as ReactClerkProvider } from '@clerk/clerk-react'; import { inBrowser } from '@clerk/shared/browser'; import { logger } from '@clerk/shared/logger'; +import dynamic from 'next/dynamic'; import { useRouter } from 'next/navigation'; import nextPackage from 'next/package.json'; import React, { useEffect, useTransition } from 'react'; @@ -10,11 +11,21 @@ import { useSafeLayoutEffect } from '../../client-boundary/hooks/useSafeLayoutEf import { ClerkNextOptionsProvider, useClerkNextOptions } from '../../client-boundary/NextOptionsContext'; import type { NextClerkProviderProps } from '../../types'; import { ClerkJSScript } from '../../utils/clerk-js-script'; +import { canUseKeyless__client } from '../../utils/feature-flags'; import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv'; +import { isNextWithUnstableServerActions } from '../../utils/sdk-versions'; import { invalidateCacheAction } from '../server-actions'; import { useAwaitablePush } from './useAwaitablePush'; import { useAwaitableReplace } from './useAwaitableReplace'; +/** + * LazyCreateKeylessApplication should only be loaded if the conditions below are met. + * Note: Using lazy() with Suspense instead of dynamic is not possible as React will throw a hydration error when `ClerkProvider` wraps `...` + */ +const LazyCreateKeylessApplication = dynamic(() => + import('./keyless-creator-reader.js').then(m => m.KeylessCreatorOrReader), +); + declare global { export interface Window { __clerk_nav_await: Array<(value: void) => void>; @@ -26,10 +37,8 @@ declare global { } } -const isDeprecatedNextjsVersion = nextPackage.version.startsWith('13.') || nextPackage.version.startsWith('14.0'); - -export const ClientClerkProvider = (props: NextClerkProviderProps) => { - if (isDeprecatedNextjsVersion) { +const NextClientClerkProvider = (props: NextClerkProviderProps) => { + if (isNextWithUnstableServerActions) { const deprecationWarning = `Clerk:\nYour current Next.js version (${nextPackage.version}) will be deprecated in the next major release of "@clerk/nextjs". Please upgrade to next@14.1.0 or later.`; if (inBrowser()) { logger.warnOnce(deprecationWarning); @@ -113,3 +122,18 @@ export const ClientClerkProvider = (props: NextClerkProviderProps) => { ); }; + +export const ClientClerkProvider = (props: NextClerkProviderProps) => { + const { children, ...rest } = props; + const safePublishableKey = mergeNextClerkPropsWithEnv(rest).publishableKey; + + if (safePublishableKey || !canUseKeyless__client) { + return {children}; + } + + return ( + + {children} + + ); +}; diff --git a/packages/nextjs/src/app-router/client/keyless-cookie-sync.tsx b/packages/nextjs/src/app-router/client/keyless-cookie-sync.tsx new file mode 100644 index 0000000000..6cc7a9cddf --- /dev/null +++ b/packages/nextjs/src/app-router/client/keyless-cookie-sync.tsx @@ -0,0 +1,23 @@ +'use client'; + +import type { AccountlessApplication } from '@clerk/backend'; +import type { PropsWithChildren } from 'react'; +import { useEffect } from 'react'; + +import { canUseKeyless__client } from '../../utils/feature-flags'; + +export function KeylessCookieSync(props: PropsWithChildren) { + useEffect(() => { + if (canUseKeyless__client) { + void import('../keyless-actions.js').then(m => + m.syncKeylessConfigAction({ + ...props, + // Preserve the current url and return back, once keys are synced in the middleware + returnUrl: window.location.href, + }), + ); + } + }, []); + + return props.children; +} diff --git a/packages/nextjs/src/app-router/client/keyless-creator-reader.tsx b/packages/nextjs/src/app-router/client/keyless-creator-reader.tsx new file mode 100644 index 0000000000..47d959dc96 --- /dev/null +++ b/packages/nextjs/src/app-router/client/keyless-creator-reader.tsx @@ -0,0 +1,25 @@ +import React, { useEffect } from 'react'; + +import type { NextClerkProviderProps } from '../../types'; +import { createOrReadKeylessAction } from '../keyless-actions'; + +export const KeylessCreatorOrReader = (props: NextClerkProviderProps) => { + const { children } = props; + const [state, fetchKeys] = React.useActionState(createOrReadKeylessAction, null); + useEffect(() => { + React.startTransition(() => { + fetchKeys(); + }); + }, []); + + if (!React.isValidElement(children)) { + return children; + } + + return React.cloneElement(children, { + key: state?.publishableKey, + publishableKey: state?.publishableKey, + __internal_claimKeylessApplicationUrl: state?.claimUrl, + __internal_bypassMissingPublishableKey: true, + } as any); +}; diff --git a/packages/nextjs/src/app-router/keyless-actions.ts b/packages/nextjs/src/app-router/keyless-actions.ts new file mode 100644 index 0000000000..cf73d6b72a --- /dev/null +++ b/packages/nextjs/src/app-router/keyless-actions.ts @@ -0,0 +1,44 @@ +'use server'; +import type { AccountlessApplication } from '@clerk/backend'; +import { cookies } from 'next/headers'; +import { redirect, RedirectType } from 'next/navigation'; + +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 }), { + 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); +} + +export async function createOrReadKeylessAction(): Promise> { + if (!canUseKeyless__server) { + return null; + } + + const result = await import('../server/keyless-node.js').then(m => m.createOrReadKeyless()); + + if (!result) { + return null; + } + + const { claimUrl, publishableKey, secretKey } = result; + + void (await cookies()).set(getKeylessCookieName(), JSON.stringify({ claimUrl, publishableKey, secretKey }), { + secure: false, + httpOnly: false, + }); + + return { + claimUrl, + publishableKey, + }; +} diff --git a/packages/nextjs/src/app-router/server/ClerkProvider.tsx b/packages/nextjs/src/app-router/server/ClerkProvider.tsx index b8c7dbd4f9..d5f609c7fd 100644 --- a/packages/nextjs/src/app-router/server/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/server/ClerkProvider.tsx @@ -1,18 +1,17 @@ import type { AuthObject } from '@clerk/backend'; import type { InitialState, Without } from '@clerk/types'; import { headers } from 'next/headers'; -import nextPkg from 'next/package.json'; import React from 'react'; import { PromisifiedAuthProvider } from '../../client-boundary/PromisifiedAuthProvider'; import { getDynamicAuthData } from '../../server/buildClerkProps'; import type { NextClerkProviderProps } from '../../types'; +import { canUseKeyless__server } from '../../utils/feature-flags'; import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv'; +import { isNext13 } from '../../utils/sdk-versions'; import { ClientClerkProvider } from '../client/ClerkProvider'; import { buildRequestLike, getScriptNonceFromHeader } from './utils'; -const isNext13 = nextPkg.version.startsWith('13.'); - const getDynamicClerkState = React.cache(async function getDynamicClerkState() { const request = await buildRequestLike(); const data = getDynamicAuthData(request); @@ -45,7 +44,11 @@ export async function ClerkProvider( } } - const output = ( + const propsWithEnvs = mergeNextClerkPropsWithEnv({ + ...rest, + }); + + let output = ( ); + const shouldRunAsKeyless = !propsWithEnvs.publishableKey && canUseKeyless__server; + + if (shouldRunAsKeyless) { + // NOTE: Create or read keys on every render. Usually this means only on hard refresh or hard navigations. + const newOrReadKeys = await import('../../server/keyless-node.js').then(mod => mod.createOrReadKeyless()); + + if (newOrReadKeys) { + const KeylessCookieSync = await import('../client/keyless-cookie-sync.js').then(mod => mod.KeylessCookieSync); + output = ( + + + {children} + + + ); + } + } + if (dynamic) { return ( // TODO: fix types so AuthObject is compatible with InitialState diff --git a/packages/nextjs/src/runtime/browser/safe-node-apis.js b/packages/nextjs/src/runtime/browser/safe-node-apis.js new file mode 100644 index 0000000000..7cad32e4d0 --- /dev/null +++ b/packages/nextjs/src/runtime/browser/safe-node-apis.js @@ -0,0 +1,7 @@ +/** + * This file is used for conditional imports to mitigate bundling issues with Next.js server actions on version prior to 14.1.0. + */ +const fs = undefined; +const path = undefined; + +module.exports = { fs, path }; diff --git a/packages/nextjs/src/runtime/node/safe-node-apis.js b/packages/nextjs/src/runtime/node/safe-node-apis.js new file mode 100644 index 0000000000..a5f4e694ae --- /dev/null +++ b/packages/nextjs/src/runtime/node/safe-node-apis.js @@ -0,0 +1,15 @@ +/** + * This file is used for conditional imports to mitigate bundling issues with Next.js server actions on version prior to 14.1.0. + */ +const { existsSync, writeFileSync, readFileSync, appendFileSync, mkdirSync, rmSync } = require('node:fs'); +const path = require('node:path'); +const fs = { + existsSync, + writeFileSync, + readFileSync, + appendFileSync, + mkdirSync, + rmSync, +}; + +module.exports = { fs, path }; diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index 73f461496e..55267dd666 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -7,9 +7,11 @@ import { NextResponse } from 'next/server'; import { isRedirect, serverRedirectWithAuth, setHeader } from '../utils'; import { withLogger } from '../utils/debugLogger'; +import { canUseKeyless__server } from '../utils/feature-flags'; import { clerkClient } from './clerkClient'; import { PUBLISHABLE_KEY, SECRET_KEY, SIGN_IN_URL, SIGN_UP_URL } from './constants'; import { errorThrower } from './errorThrower'; +import { getKeylessCookieValue } from './keyless'; import { clerkMiddlewareRequestDataStorage, clerkMiddlewareRequestDataStore } from './middleware-storage'; import { isNextjsNotFoundError, @@ -36,6 +38,7 @@ export type ClerkMiddlewareAuthObject = AuthObject & { export interface ClerkMiddlewareAuth { (): Promise; + protect: AuthProtect; } @@ -45,7 +48,9 @@ type ClerkMiddlewareHandler = ( event: NextMiddlewareEvtParam, ) => NextMiddlewareReturn; -export type ClerkMiddlewareOptions = AuthenticateRequestOptions & { debug?: boolean }; +export type ClerkMiddlewareOptions = AuthenticateRequestOptions & { + debug?: boolean; +}; type ClerkMiddlewareOptionsCallback = (req: NextRequest) => ClerkMiddlewareOptions; @@ -85,14 +90,18 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]) => { const [handler, params] = parseHandlerAndOptions(args); return clerkMiddlewareRequestDataStorage.run(clerkMiddlewareRequestDataStore, () => { - const nextMiddleware: NextMiddleware = withLogger('clerkMiddleware', logger => async (request, event) => { + const baseNextMiddleware: NextMiddleware = withLogger('clerkMiddleware', logger => async (request, event) => { // Handles the case where `options` is a callback function to dynamically access `NextRequest` const resolvedParams = typeof params === 'function' ? params(request) : params; - const publishableKey = assertKey(resolvedParams.publishableKey || PUBLISHABLE_KEY, () => - errorThrower.throwMissingPublishableKeyError(), + const keyless = getKeylessCookieValue(name => request.cookies.get(name)?.value); + + const publishableKey = assertKey( + resolvedParams.publishableKey || PUBLISHABLE_KEY || keyless?.publishableKey, + () => errorThrower.throwMissingPublishableKeyError(), ); - const secretKey = assertKey(resolvedParams.secretKey || SECRET_KEY, () => + + const secretKey = assertKey(resolvedParams.secretKey || SECRET_KEY || keyless?.secretKey, () => errorThrower.throwMissingSecretKeyError(), ); const signInUrl = resolvedParams.signInUrl || SIGN_IN_URL; @@ -108,7 +117,6 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]) => { // Propagates the request data to be accessed on the server application runtime from helpers such as `clerkClient` clerkMiddlewareRequestDataStore.set('requestData', options); - const resolvedClerkClient = await clerkClient(); resolvedClerkClient.telemetry.record( @@ -183,11 +191,44 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]) => { setRequestHeadersOnNextResponse(handlerResult, clerkRequest, { [constants.Headers.EnableDebug]: 'true' }); } - decorateRequest(clerkRequest, handlerResult, requestState, resolvedParams); + decorateRequest(clerkRequest, handlerResult, requestState, { ...resolvedParams, ...options }); return handlerResult; }); + const keylessMiddleware: NextMiddleware = async (request, event) => { + /** + * This mechanism replaces a full-page reload. Ensures that middleware will re-run and authenticate the request properly without the secret key or publishable key to be missing. + */ + if (isKeylessSyncRequest(request)) { + return returnBackFromKeylessSync(request); + } + + const resolvedParams = typeof params === 'function' ? params(request) : params; + const keyless = getKeylessCookieValue(name => request.cookies.get(name)?.value); + const isMissingPublishableKey = !(resolvedParams.publishableKey || PUBLISHABLE_KEY || keyless?.publishableKey); + /** + * In keyless mode, if the publishable key is missing, let the request through, to render `` that will resume the flow gracefully. + */ + if (isMissingPublishableKey) { + const res = NextResponse.next(); + setRequestHeadersOnNextResponse(res, request, { + [constants.Headers.AuthStatus]: 'signed-out', + }); + return res; + } + + return baseNextMiddleware(request, event); + }; + + const nextMiddleware: NextMiddleware = async (request, event) => { + if (canUseKeyless__server) { + return keylessMiddleware(request, event); + } + + return baseNextMiddleware(request, event); + }; + // If we have a request and event, we're being called as a middleware directly // eg, export default clerkMiddleware; if (request && event) { @@ -214,6 +255,17 @@ const parseHandlerAndOptions = (args: unknown[]) => { ] as [ClerkMiddlewareHandler | undefined, ClerkMiddlewareOptions | ClerkMiddlewareOptionsCallback]; }; +const isKeylessSyncRequest = (request: NextMiddlewareRequestParam) => + request.nextUrl.pathname === '/clerk-sync-keyless'; + +const returnBackFromKeylessSync = (request: NextMiddlewareRequestParam) => { + const returnUrl = request.nextUrl.searchParams.get('returnUrl'); + const url = new URL(request.url); + url.pathname = ''; + + return NextResponse.redirect(returnUrl || url.toString()); +}; + type AuthenticateRequest = Pick['authenticateRequest']; export const createAuthenticateRequestOptions = ( diff --git a/packages/nextjs/src/server/constants.ts b/packages/nextjs/src/server/constants.ts index 36ec73b95e..67c7295bbd 100644 --- a/packages/nextjs/src/server/constants.ts +++ b/packages/nextjs/src/server/constants.ts @@ -21,3 +21,5 @@ export const SDK_METADATA = { export const TELEMETRY_DISABLED = isTruthy(process.env.NEXT_PUBLIC_CLERK_TELEMETRY_DISABLED); export const TELEMETRY_DEBUG = isTruthy(process.env.NEXT_PUBLIC_CLERK_TELEMETRY_DEBUG); + +export const ENABLE_KEYLESS = isTruthy(process.env.NEXT_PUBLIC_CLERK_ENABLE_KEYLESS); diff --git a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts index 69cbe911ad..c276a29fc5 100644 --- a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts +++ b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts @@ -3,7 +3,7 @@ import { AuthStatus, constants, signedInAuthObject, signedOutAuthObject } from ' import { decodeJwt } from '@clerk/backend/jwt'; import type { LoggerNoCommit } from '../../utils/debugLogger'; -import { API_URL, API_VERSION, SECRET_KEY } from '../constants'; +import { API_URL, API_VERSION, PUBLISHABLE_KEY, SECRET_KEY } from '../constants'; import type { RequestLike } from '../types'; import { assertTokenSignature, decryptClerkRequestData, getAuthKeyFromRequest, getHeader } from '../utils'; @@ -28,6 +28,7 @@ export function getAuthDataFromRequest( const options = { secretKey: opts?.secretKey || decryptedRequestData.secretKey || SECRET_KEY, + publishableKey: decryptedRequestData.publishableKey || PUBLISHABLE_KEY, apiUrl: API_URL, apiVersion: API_VERSION, authStatus, diff --git a/packages/nextjs/src/server/keyless-node.ts b/packages/nextjs/src/server/keyless-node.ts new file mode 100644 index 0000000000..bd0982a4b7 --- /dev/null +++ b/packages/nextjs/src/server/keyless-node.ts @@ -0,0 +1,173 @@ +import type { AccountlessApplication } from '@clerk/backend'; +import { logger } from '@clerk/shared/logger'; + +/** + * Attention: Only import this module when the node runtime is used. + * We are using conditional imports to mitigate bundling issues with Next.js server actions on version prior to 14.1.0. + */ +// @ts-ignore +import nodeRuntime from '#safe-node-apis'; + +import { createClerkClientWithOptions } from './createClerkClient'; + +/** + * The Clerk-specific directory name. + */ +const CLERK_HIDDEN = '.clerk'; + +/** + * The Clerk-specific lock file that is used to mitigate multiple key creation. + * This is automatically cleaned up. + */ +const CLERK_LOCK = 'clerk.lock'; + +/** + * The `.clerk/` is NOT safe to be commited as it may include sensitive information about a Clerk instance. + * It may include an instance's secret key and the secret token for claiming that instance. + */ +function updateGitignore() { + if (!nodeRuntime.fs) { + throw "Clerk: fsModule.fs is missing. This is an internal error. Please contact Clerk's support."; + } + const { existsSync, writeFileSync, readFileSync, appendFileSync } = nodeRuntime.fs; + + if (!nodeRuntime.path) { + throw "Clerk: fsModule.path is missing. This is an internal error. Please contact Clerk's support."; + } + const gitignorePath = nodeRuntime.path.join(process.cwd(), '.gitignore'); + if (!existsSync(gitignorePath)) { + writeFileSync(gitignorePath, ''); + } + + // Check if `.clerk/` entry exists in .gitignore + const gitignoreContent = readFileSync(gitignorePath, 'utf-8'); + if (!gitignoreContent.includes(CLERK_HIDDEN + '/')) { + appendFileSync(gitignorePath, `\n${CLERK_HIDDEN}/\n`); + } +} + +const generatePath = (...slugs: string[]) => { + if (!nodeRuntime.path) { + throw "Clerk: fsModule.path is missing. This is an internal error. Please contact Clerk's support."; + } + return nodeRuntime.path.join(process.cwd(), CLERK_HIDDEN, ...slugs); +}; + +const _TEMP_DIR_NAME = '.tmp'; +const getKeylessConfigurationPath = () => generatePath(_TEMP_DIR_NAME, 'keyless.json'); +const getKeylessReadMePath = () => generatePath(_TEMP_DIR_NAME, 'README.md'); + +let isCreatingFile = false; + +function safeParseClerkFile(): AccountlessApplication | undefined { + if (!nodeRuntime.fs) { + throw "Clerk: fsModule.fs is missing. This is an internal error. Please contact Clerk's support."; + } + const { readFileSync } = nodeRuntime.fs; + try { + const CONFIG_PATH = getKeylessConfigurationPath(); + let fileAsString; + try { + fileAsString = readFileSync(CONFIG_PATH, { encoding: 'utf-8' }) || '{}'; + } catch { + fileAsString = '{}'; + } + return JSON.parse(fileAsString) as AccountlessApplication; + } catch { + return undefined; + } +} + +const createMessage = (keys: AccountlessApplication) => { + return `\n\x1b[35m\n[Clerk]:\x1b[0m You are running on keyless mode.\nYou can \x1b[35mclaim your keys\x1b[0m by visiting ${keys.claimUrl}\n`; +}; + +async function createOrReadKeyless(): Promise { + if (!nodeRuntime.fs) { + // This should never happen. + throw "Clerk: fsModule.fs is missing. This is an internal error. Please contact Clerk's support."; + } + const { existsSync, writeFileSync, mkdirSync, rmSync } = nodeRuntime.fs; + + /** + * If another request is already in the process of acquiring keys return early. + * Using both an in-memory and file system lock seems to be the most effective solution. + */ + if (isCreatingFile || existsSync(CLERK_LOCK)) { + return undefined; + } + + isCreatingFile = true; + + writeFileSync( + CLERK_LOCK, + // In the rare case, the file persists give the developer enough context. + 'This file can be deleted. Please delete this file and refresh your application', + { + encoding: 'utf8', + mode: '0777', + flag: 'w', + }, + ); + + const CONFIG_PATH = getKeylessConfigurationPath(); + const README_PATH = getKeylessReadMePath(); + + mkdirSync(generatePath(_TEMP_DIR_NAME), { recursive: true }); + updateGitignore(); + + /** + * When the configuration file exists, always read the keys from the file + */ + const envVarsMap = safeParseClerkFile(); + if (envVarsMap?.publishableKey && envVarsMap?.secretKey) { + isCreatingFile = false; + rmSync(CLERK_LOCK, { force: true, recursive: true }); + + /** + * Notify developers. + */ + logger.logOnce(createMessage(envVarsMap)); + + return envVarsMap; + } + + /** + * At this step, it is safe to create new keys and store them. + */ + const client = createClerkClientWithOptions({}); + const accountlessApplication = await client.__experimental_accountlessApplications.createAccountlessApplication(); + + /** + * Notify developers. + */ + logger.logOnce(createMessage(accountlessApplication)); + + writeFileSync(CONFIG_PATH, JSON.stringify(accountlessApplication), { + encoding: 'utf8', + mode: '0777', + flag: 'w', + }); + + // TODO-KEYLESS: Add link to official documentation. + const README_NOTIFICATION = ` +## DO NOT COMMIT +This directory is auto-generated from \`@clerk/nextjs\` because you are running on Keyless mode. Avoid committing the \`.clerk/\` directory as it includes the secret key of the unclaimed instance. + `; + + writeFileSync(README_PATH, README_NOTIFICATION, { + encoding: 'utf8', + mode: '0777', + flag: 'w', + }); + + /** + * Clean up locks. + */ + rmSync(CLERK_LOCK, { force: true, recursive: true }); + isCreatingFile = false; + + return accountlessApplication; +} + +export { createOrReadKeyless }; diff --git a/packages/nextjs/src/server/keyless.ts b/packages/nextjs/src/server/keyless.ts new file mode 100644 index 0000000000..d2b49699fe --- /dev/null +++ b/packages/nextjs/src/server/keyless.ts @@ -0,0 +1,49 @@ +import type { AccountlessApplication } from '@clerk/backend'; +import hex from 'crypto-js/enc-hex'; +import sha256 from 'crypto-js/sha256'; + +import { canUseKeyless__server } from '../utils/feature-flags'; + +const keylessCookiePrefix = `__clerk_keys_`; + +const getKeylessCookieName = (): string => { + // eslint-disable-next-line turbo/no-undeclared-env-vars + const PATH = process.env.PWD; + + // Handle gracefully missing PWD + if (!PATH) { + return `${keylessCookiePrefix}${0}`; + } + + const lastThreeDirs = PATH.split('/').filter(Boolean).slice(-3).reverse().join('/'); + + // Hash the resulting string + const hash = hashString(lastThreeDirs); + + return `${keylessCookiePrefix}${hash}`; +}; + +function hashString(str: string) { + return sha256(str).toString(hex).slice(0, 16); // Take only the first 16 characters +} + +function getKeylessCookieValue(getter: (cookieName: string) => string | undefined): AccountlessApplication | undefined { + if (!canUseKeyless__server) { + return undefined; + } + + const keylessCookieName = getKeylessCookieName(); + let keyless; + + try { + if (keylessCookieName) { + keyless = JSON.parse(getter(keylessCookieName) || '{}'); + } + } catch { + keyless = undefined; + } + + return keyless; +} + +export { getKeylessCookieValue, getKeylessCookieName }; diff --git a/packages/nextjs/src/server/utils.ts b/packages/nextjs/src/server/utils.ts index 175251133a..30ad5018d8 100644 --- a/packages/nextjs/src/server/utils.ts +++ b/packages/nextjs/src/server/utils.ts @@ -3,7 +3,7 @@ import { constants } from '@clerk/backend/internal'; import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; import { logger } from '@clerk/shared/logger'; import { isHttpOrHttps } from '@clerk/shared/proxy'; -import { handleValueOrFn } from '@clerk/shared/utils'; +import { handleValueOrFn, isProductionEnvironment } from '@clerk/shared/utils'; import AES from 'crypto-js/aes'; import encUtf8 from 'crypto-js/enc-utf8'; import hmacSHA1 from 'crypto-js/hmac-sha1'; @@ -97,7 +97,7 @@ export function decorateRequest( req: ClerkRequest, res: Response, requestState: RequestState, - requestData?: AuthenticateRequestOptions, + requestData: AuthenticateRequestOptions, ): Response { const { reason, message, status, token } = requestState; // pass-through case, convert to next() @@ -195,7 +195,7 @@ export function assertAuthStatus(req: RequestLike, error: string) { } } -export function assertKey(key: string, onError: () => never): string { +export function assertKey(key: string | undefined, onError: () => never): string { if (!key) { onError(); } @@ -224,6 +224,8 @@ export function assertTokenSignature(token: string, key: string, signature?: str } } +const KEYLESS_ENCRYPTION_KEY = 'clerk_keyless_dummy_key'; + /** * Encrypt request data propagated between server requests. * @internal @@ -233,7 +235,7 @@ export function encryptClerkRequestData(requestData?: Partial errorThrower.throwMissingSecretKeyError()), - ).toString(); + const maybeKeylessEncryptionKey = isProductionEnvironment() + ? ENCRYPTION_KEY || assertKey(SECRET_KEY, () => errorThrower.throwMissingSecretKeyError()) + : ENCRYPTION_KEY || SECRET_KEY || KEYLESS_ENCRYPTION_KEY; + + return AES.encrypt(JSON.stringify(requestData), maybeKeylessEncryptionKey).toString(); } /** @@ -259,8 +262,12 @@ export function decryptClerkRequestData( return {}; } + const maybeKeylessEncryptionKey = isProductionEnvironment() + ? ENCRYPTION_KEY || SECRET_KEY + : ENCRYPTION_KEY || SECRET_KEY || KEYLESS_ENCRYPTION_KEY; + try { - const decryptedBytes = AES.decrypt(encryptedRequestData, ENCRYPTION_KEY || SECRET_KEY); + const decryptedBytes = AES.decrypt(encryptedRequestData, maybeKeylessEncryptionKey); const encoded = decryptedBytes.toString(encUtf8); return JSON.parse(encoded); } catch (err) { diff --git a/packages/nextjs/src/utils/clerk-js-script.tsx b/packages/nextjs/src/utils/clerk-js-script.tsx index c678d318ab..cc39f3534e 100644 --- a/packages/nextjs/src/utils/clerk-js-script.tsx +++ b/packages/nextjs/src/utils/clerk-js-script.tsx @@ -12,10 +12,18 @@ type ClerkJSScriptProps = { function ClerkJSScript(props: ClerkJSScriptProps) { const { publishableKey, clerkJSUrl, clerkJSVersion, clerkJSVariant, nonce } = useClerkNextOptions(); const { domain, proxyUrl } = useClerk(); + + /** + * If no publishable key, avoid appending an invalid script in the DOM. + */ + if (!publishableKey) { + return null; + } + const options = { domain, proxyUrl, - publishableKey: publishableKey!, + publishableKey, clerkJSUrl, clerkJSVersion, clerkJSVariant, diff --git a/packages/nextjs/src/utils/feature-flags.ts b/packages/nextjs/src/utils/feature-flags.ts new file mode 100644 index 0000000000..0d59bdd9a6 --- /dev/null +++ b/packages/nextjs/src/utils/feature-flags.ts @@ -0,0 +1,9 @@ +import { isDevelopmentEnvironment } from '@clerk/shared/utils'; + +import { ENABLE_KEYLESS } from '../server/constants'; +import { isNextWithUnstableServerActions } from './sdk-versions'; + +const canUseKeyless__server = !isNextWithUnstableServerActions && isDevelopmentEnvironment() && ENABLE_KEYLESS; +const canUseKeyless__client = !isNextWithUnstableServerActions && ENABLE_KEYLESS; + +export { canUseKeyless__client, canUseKeyless__server }; diff --git a/packages/nextjs/src/utils/sdk-versions.ts b/packages/nextjs/src/utils/sdk-versions.ts new file mode 100644 index 0000000000..729a096a6f --- /dev/null +++ b/packages/nextjs/src/utils/sdk-versions.ts @@ -0,0 +1,11 @@ +import nextPkg from 'next/package.json'; + +const isNext13 = nextPkg.version.startsWith('13.'); + +/** + * Those versions are affected by a bundling issue that will break the application if `node:fs` is used inside a server function. + * The affected versions are >=next@13.5.4 and <=next@14.0.4 + */ +const isNextWithUnstableServerActions = isNext13 || nextPkg.version.startsWith('14.0'); + +export { isNext13, isNextWithUnstableServerActions }; diff --git a/packages/nextjs/tsup.config.ts b/packages/nextjs/tsup.config.ts index 8edffed730..0873f41802 100644 --- a/packages/nextjs/tsup.config.ts +++ b/packages/nextjs/tsup.config.ts @@ -10,13 +10,19 @@ export default defineConfig(overrideOptions => { const shouldPublish = !!overrideOptions.env?.publish; const common: Options = { - entry: ['./src/**/*.{ts,tsx,js,jsx}', '!./src/**/*.test.{ts,tsx}', '!./src/**/server-actions.ts'], + entry: [ + './src/**/*.{ts,tsx,js,jsx}', + '!./src/**/*.test.{ts,tsx}', + '!./src/**/server-actions.ts', + '!./src/**/keyless-actions.ts', + ], // We want to preserve original file structure // so that the "use client" directives are not lost // and make debugging easier via node_modules easier bundle: false, clean: true, minify: false, + external: ['#safe-node-apis'], sourcemap: true, legacyOutput: true, define: { @@ -39,13 +45,13 @@ export default defineConfig(overrideOptions => { const serverActionsEsm: Options = { ...esm, - entry: ['./src/**/server-actions.ts'], + entry: ['./src/**/server-actions.ts', './src/**/keyless-actions.ts'], sourcemap: false, }; const serverActionsCjs: Options = { ...cjs, - entry: ['./src/**/server-actions.ts'], + entry: ['./src/**/server-actions.ts', './src/**/keyless-actions.ts'], sourcemap: false, }; @@ -55,6 +61,8 @@ export default defineConfig(overrideOptions => { // Happy to improve this if there is a better way const moveServerActions = (format: 'esm' | 'cjs') => `mv ./dist/${format}/server-actions.js ./dist/${format}/app-router`; + const moveKeylessActions = (format: 'esm' | 'cjs') => + `mv ./dist/${format}/keyless-actions.js ./dist/${format}/app-router`; return runAfterLast([ 'pnpm build:declarations', @@ -62,6 +70,8 @@ export default defineConfig(overrideOptions => { copyPackageJson('cjs'), moveServerActions('esm'), moveServerActions('cjs'), + moveKeylessActions('esm'), + moveKeylessActions('cjs'), shouldPublish && 'pnpm publish:local', ])(esm, cjs, serverActionsEsm, serverActionsCjs); }); diff --git a/packages/react/src/contexts/ClerkContextProvider.tsx b/packages/react/src/contexts/ClerkContextProvider.tsx index 2c83c4bae5..00b2c153df 100644 --- a/packages/react/src/contexts/ClerkContextProvider.tsx +++ b/packages/react/src/contexts/ClerkContextProvider.tsx @@ -106,6 +106,7 @@ const useLoadedIsomorphicClerk = (options: IsomorphicClerkOptions) => { React.useEffect(() => { return () => { IsomorphicClerk.clearInstance(); + setLoaded(false); }; }, []); diff --git a/packages/react/src/contexts/ClerkProvider.tsx b/packages/react/src/contexts/ClerkProvider.tsx index 62262d8e8c..cc730af2f3 100644 --- a/packages/react/src/contexts/ClerkProvider.tsx +++ b/packages/react/src/contexts/ClerkProvider.tsx @@ -8,10 +8,10 @@ import { withMaxAllowedInstancesGuard } from '../utils'; import { ClerkContextProvider } from './ClerkContextProvider'; function ClerkProviderBase(props: ClerkProviderProps): JSX.Element { - const { initialState, children, ...restIsomorphicClerkOptions } = props; + const { initialState, children, __internal_bypassMissingPublishableKey, ...restIsomorphicClerkOptions } = props; const { publishableKey = '', Clerk: userInitialisedClerk } = restIsomorphicClerkOptions; - if (!userInitialisedClerk) { + if (!userInitialisedClerk && !__internal_bypassMissingPublishableKey) { if (!publishableKey) { errorThrower.throwMissingPublishableKeyError(); } else if (publishableKey && !isPublishableKey(publishableKey)) { diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index ebf6990ddb..fe13eb7ac8 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -227,7 +227,13 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { // During CSR: use the cached instance for the whole lifetime of the app // Also will recreate the instance if the provided Clerk instance changes // This method should be idempotent in both scenarios - if (!inBrowser() || !this.#instance || (options.Clerk && this.#instance.Clerk !== options.Clerk)) { + if ( + !inBrowser() || + !this.#instance || + (options.Clerk && this.#instance.Clerk !== options.Clerk) || + // Allow hot swapping PKs on the client + this.#instance.publishableKey !== options.publishableKey + ) { this.#instance = new IsomorphicClerk(options); } return this.#instance; @@ -278,7 +284,9 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { this.options.sdkMetadata = SDK_METADATA; } - void this.loadClerkJS(); + if (this.#publishableKey) { + void this.loadClerkJS(); + } } get sdkMetadata(): SDKMetadata | undefined { diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 23db387c31..eb1326d8d7 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -55,6 +55,11 @@ export type ClerkProviderProps = IsomorphicClerkOptions & { * Provide an initial state of the Clerk client during server-side rendering (SSR) */ initialState?: InitialState; + /** + * Indicates to silently fail the initialization process when the publishable keys is not provided, instead of throwing an error. + * Defaults to `false`. + */ + __internal_bypassMissingPublishableKey?: boolean; }; export interface BrowserClerkConstructor { diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 9265e8f63b..4f59b332f3 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -752,7 +752,7 @@ export type ClerkOptions = ClerkOptionsNavigation & Record >; - __internal_claimAccountlessKeysUrl?: string; + __internal_claimKeylessApplicationUrl?: string; /** * [EXPERIMENTAL] Provide the underlying host router, required for the new experimental UI components.