Skip to content

feat(nextjs): Support Keyless mode #4602

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 35 commits into from
Dec 5, 2024
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
202e089
add changesets
panteliselef Nov 19, 2024
ef1e29a
chore(backend): Create experimental accountless api
panteliselef Nov 19, 2024
55e1f2d
Support accountless sync and creation in Server and Client ClerkProvi…
panteliselef Nov 19, 2024
86758da
Update `@clerk/react` to not throw when pk is missing
panteliselef Nov 19, 2024
af91677
Add missing changes to Server clerk provider
panteliselef Nov 19, 2024
0b40c97
WIP
panteliselef Nov 19, 2024
0646cc9
WIP 2
panteliselef Nov 20, 2024
5e0181d
fix: Gracefully handle problematic nextjs version with server actions
panteliselef Nov 21, 2024
07d7a08
Merge branch 'refs/heads/main' into elef/user-1072-support-accountles…
panteliselef Nov 21, 2024
dbfb097
Replace Suspense with dynamic()
panteliselef Nov 21, 2024
7f0020a
fix: Support returnUrl when syncing with middleware
panteliselef Nov 21, 2024
293b15b
feat: Add UI prompt
panteliselef Nov 21, 2024
e971a2d
fix: Comply with rendering modes
panteliselef Nov 21, 2024
c4b1ac5
feat: Use UI prompt in sandbox
panteliselef Nov 21, 2024
afd7dc9
fix(nextjs): Add internal prefix to tokenUrl prop
panteliselef Nov 21, 2024
8e1b64f
Merge branch 'refs/heads/main' into elef/user-1072-support-accountles…
panteliselef Nov 26, 2024
ca1df30
chore(nextjs): Cleanup and add comments with context
panteliselef Nov 26, 2024
41bc200
add ascii art for accountless
panteliselef Nov 27, 2024
b27445d
make it opt-in with an env ff
panteliselef Nov 27, 2024
29abac2
address feedback about variable names
panteliselef Dec 2, 2024
92efadc
Merge branch 'refs/heads/main' into elef/user-1072-support-accountles…
panteliselef Dec 2, 2024
5342e15
improve jsdocs about node specific apis
panteliselef Dec 2, 2024
b594ace
Merge branch 'refs/heads/main' into elef/user-1072-support-accountles…
panteliselef Dec 2, 2024
0888c61
rebranding to keyless
panteliselef Dec 2, 2024
eded662
rebrand to keyless p2
panteliselef Dec 4, 2024
fd12da6
Add readme along the keys file to notify developers to not commit
panteliselef Dec 4, 2024
bc7fc4c
drop dynamicConfig
panteliselef Dec 4, 2024
1b203ae
refactor keylessMiddleware
panteliselef Dec 4, 2024
b2bad5e
Merge branch 'refs/heads/main' into elef/user-1072-support-accountles…
panteliselef Dec 4, 2024
c0210e1
minor changes in file writing
panteliselef Dec 5, 2024
8df06f3
add changesets
panteliselef Dec 5, 2024
06c1f73
Apply suggestions from code review
panteliselef Dec 5, 2024
2950e68
Merge branch 'refs/heads/main' into elef/user-1072-support-accountles…
panteliselef Dec 5, 2024
206bb4f
use next/headers
panteliselef Dec 5, 2024
b04a9d2
keep `#safe-node-apis` imports simple
panteliselef Dec 5, 2024
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
6 changes: 6 additions & 0 deletions .changeset/brown-items-reflect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': minor
'@clerk/types': minor
---

accountless
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once we're ready, let's expand the changeset description here. And consolidate the two changeset files.

6 changes: 6 additions & 0 deletions .changeset/poor-books-hug.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/backend': minor
'@clerk/nextjs': minor
---

accountless
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo

6 changes: 3 additions & 3 deletions integration/tests/next-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ClerkProvider dynamic>
Expand All @@ -147,10 +147,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
() => `'use client';

import { useAuth } from '@clerk/nextjs';

export function ClientComponent() {
useAuth();

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is just auto formatting

return <p>I am dynamically rendered</p>;
}
`,
Expand Down
13 changes: 13 additions & 0 deletions packages/backend/src/api/endpoints/AccountlessApplicationsAPI.ts
Original file line number Diff line number Diff line change
@@ -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<AccountlessApplication>({
method: 'POST',
path: basePath,
});
}
}
1 change: 1 addition & 0 deletions packages/backend/src/api/endpoints/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './AccountlessApplicationsAPI';
export * from './AbstractApi';
export * from './AllowlistIdentifierApi';
export * from './ClientApi';
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/api/factory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
AccountlessApplicationAPI,
AllowlistIdentifierAPI,
ClientAPI,
DomainAPI,
Expand All @@ -23,6 +24,9 @@ export function createBackendApiClient(options: CreateBackendApiOptions) {
const request = buildRequest(options);

return {
__experimental_accountlessApplications: new AccountlessApplicationAPI(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'd like to have this as experimental for a while

buildRequest({ ...options, requireSecretKey: false }),
),
allowlistIdentifiers: new AllowlistIdentifierAPI(request),
clients: new ClientAPI(request),
emailAddresses: new EmailAddressAPI(request),
Expand Down
17 changes: 15 additions & 2 deletions packages/backend/src/api/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <T>(requestOptions: ClerkBackendApiRequestOptions): Promise<ClerkBackendApiResponse<T>> => {
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);

Expand Down
13 changes: 13 additions & 0 deletions packages/backend/src/api/resources/AccountlessApplication.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
3 changes: 3 additions & 0 deletions packages/backend/src/api/resources/Deserializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
Token,
User,
} from '.';
import { AccountlessApplication } from './AccountlessApplication';
import type { PaginatedResponseJSON } from './JSON';
import { ObjectType } from './JSON';

Expand Down Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions packages/backend/src/api/resources/JSON.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
} from './Enums';

export const ObjectType = {
AccountlessApplication: 'accountless_application',
AllowlistIdentifier: 'allowlist_identifier',
Client: 'client',
Email: 'email',
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/api/resources/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './AccountlessApplication';
export * from './AllowlistIdentifier';
export * from './Client';
export * from './DeletedObject';
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export type { VerifyTokenOptions } from './tokens/verify';
* JSON types
*/
export type {
AccountlessApplicationJSON,
ClerkResourceJSON,
TokenJSON,
AllowlistIdentifierJSON,
Expand Down Expand Up @@ -87,6 +88,7 @@ export type {
* Resources
*/
export type {
AccountlessApplication,
AllowlistIdentifier,
Client,
EmailAddress,
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/sandbox/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() ?? {});
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2012,9 +2012,9 @@ export class Clerk implements ClerkInterface {
#handleAccountlessPrompt = () => {
if (__BUILD_FLAG_ACCOUNTLESS_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 },
});
}
});
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/ui/Components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -518,9 +518,9 @@ const Components = (props: ComponentsProps) => {
)}

{__BUILD_FLAG_ACCOUNTLESS_UI__
? state.options?.__internal_claimAccountlessKeysUrl && (
? state.options?.__internal_claimKeylessApplicationUrl && (
<LazyImpersonationFabProvider globalAppearance={state.appearance}>
<AccountlessPrompt url={state.options.__internal_claimAccountlessKeysUrl} />
<AccountlessPrompt url={state.options.__internal_claimKeylessApplicationUrl} />
</LazyImpersonationFabProvider>
)
: null}
Expand Down
7 changes: 7 additions & 0 deletions packages/nextjs/package.cjs.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
"#components": {
"react-server": "./components.server.js",
"default": "./components.client.js"
},
"#safe-node-apis": {
"edge-light": "./runtime/browser/safe-node-apis.js",
"worker": "./runtime/browser/safe-node-apis.js",
"browser": "./runtime/browser/safe-node-apis.js",
"node": "./runtime/node/safe-node-apis.js",
"default": "./runtime/browser/safe-node-apis.js"
}
}
}
7 changes: 7 additions & 0 deletions packages/nextjs/package.esm.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
"#components": {
"react-server": "./components.server.js",
"default": "./components.client.js"
},
"#safe-node-apis": {
"edge-light": "./runtime/browser/safe-node-apis.js",
"worker": "./runtime/browser/safe-node-apis.js",
"browser": "./runtime/browser/safe-node-apis.js",
"node": "./runtime/node/safe-node-apis.js",
"default": "./runtime/browser/safe-node-apis.js"
}
}
}
42 changes: 42 additions & 0 deletions packages/nextjs/src/app-router/accountless-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use server';
import type { AccountlessApplication } from '@clerk/backend';
import { getCookies } from 'ezheaders';
import { redirect, RedirectType } from 'next/navigation';

import { getKeylessCookieName } from '../server/accountless';
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 getCookies()).set(getKeylessCookieName(), JSON.stringify({ claimUrl, publishableKey, secretKey }), {
secure: true,
httpOnly: true,
});

// TODO-ACCOUNTLESS: Do we even need this ? I think setting the cookie will reset the router cache.
redirect(`/clerk-sync-keyless?returnUrl=${returnUrl}`, RedirectType.replace);
}

export async function createKeylessApplicationAction(): Promise<null | Omit<AccountlessApplication, 'secretKey'>> {
if (!canUseKeyless__server) {
return null;
}

const result = await import('../server/accountless-node.js').then(m => m.createOrReadKeyless());

if (!result) {
return null;
}

const { claimUrl, publishableKey, secretKey } = result;

void (await getCookies()).set(getKeylessCookieName(), JSON.stringify({ claimUrl, publishableKey, secretKey }), {
secure: false,
httpOnly: false,
});

return {
claimUrl,
publishableKey,
};
}
32 changes: 28 additions & 4 deletions packages/nextjs/src/app-router/client/ClerkProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

/**
* Accountless creator 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 `<html><body>...`
*/
const LazyCreateKeylessApplication = dynamic(() =>
import('./lazy-accountless-creator.js').then(m => m.CreateKeylessApplication),
);

declare global {
export interface Window {
__clerk_nav_await: Array<(value: void) => void>;
Expand All @@ -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 [email protected] or later.`;
if (inBrowser()) {
logger.warnOnce(deprecationWarning);
Expand Down Expand Up @@ -113,3 +122,18 @@ export const ClientClerkProvider = (props: NextClerkProviderProps) => {
</ClerkNextOptionsProvider>
);
};

export const ClientClerkProvider = (props: NextClerkProviderProps) => {
const { children, ...rest } = props;
const safePublishableKey = mergeNextClerkPropsWithEnv(rest).publishableKey;

if (safePublishableKey || !canUseKeyless__client) {
return <NextClientClerkProvider {...rest}>{children}</NextClientClerkProvider>;
}

return (
<LazyCreateKeylessApplication>
<NextClientClerkProvider {...rest}>{children}</NextClientClerkProvider>
</LazyCreateKeylessApplication>
);
};
23 changes: 23 additions & 0 deletions packages/nextjs/src/app-router/client/accountless-cookie-sync.tsx
Original file line number Diff line number Diff line change
@@ -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<AccountlessApplication>) {
useEffect(() => {
if (canUseKeyless__client) {
void import('../accountless-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;
}
25 changes: 25 additions & 0 deletions packages/nextjs/src/app-router/client/lazy-accountless-creator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React, { useEffect } from 'react';

import type { NextClerkProviderProps } from '../../types';
import { createKeylessApplicationAction } from '../accountless-actions';

export const CreateKeylessApplication = (props: NextClerkProviderProps) => {
const { children } = props;
const [state, fetchKeys] = React.useActionState(createKeylessApplicationAction, 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);
};
Loading
Loading