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 3 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

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, skipSecretKey: true }),
),
allowlistIdentifiers: new AllowlistIdentifierAPI(request),
clients: new ClientAPI(request),
emailAddresses: new EmailAddressAPI(request),
Expand Down
14 changes: 12 additions & 2 deletions packages/backend/src/api/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,23 @@ type BuildRequestOptions = {
apiVersion?: string;
/* Library/SDK name */
userAgent?: string;

skipSecretKey?: boolean;
Copy link
Member Author

Choose a reason for hiding this comment

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

the endpoint for creating the accountless keys doesn't require a secret key

};
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,
skipSecretKey = false,
apiUrl = API_URL,
apiVersion = API_VERSION,
userAgent = USER_AGENT,
} = options;
const { path, method, queryParams, headerParams, bodyParams, formData } = requestOptions;

assertValidSecretKey(secretKey);
if (!skipSecretKey) {
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
41 changes: 39 additions & 2 deletions packages/nextjs/src/app-router/client/ClerkProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { ClerkNextOptionsProvider, useClerkNextOptions } from '../../client-boun
import type { NextClerkProviderProps } from '../../types';
import { ClerkJSScript } from '../../utils/clerk-js-script';
import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv';
import { invalidateCacheAction } from '../server-actions';
import { createAccountlessKeysAction, invalidateCacheAction } from '../server-actions';
import { useAwaitablePush } from './useAwaitablePush';
import { useAwaitableReplace } from './useAwaitableReplace';

Expand All @@ -23,7 +23,7 @@ declare global {
}
}

export const ClientClerkProvider = (props: NextClerkProviderProps) => {
const __ClientClerkProvider = (props: NextClerkProviderProps) => {
const { __unstable_invokeMiddlewareOnAuthStateChange = true, children } = props;
const router = useRouter();
const push = useAwaitablePush();
Expand Down Expand Up @@ -99,3 +99,40 @@ export const ClientClerkProvider = (props: NextClerkProviderProps) => {
</ClerkNextOptionsProvider>
);
};

const AccountlessCreateKeys = (props: NextClerkProviderProps) => {
const { children } = props;
const [state, fetchKeys] = React.useActionState(createAccountlessKeysAction, null);
useEffect(() => {
React.startTransition(() => {
fetchKeys();
});
}, []);

if (!React.isValidElement(children)) {
return children;
}

return React.cloneElement(children, {
key: state?.publishableKey,
publishableKey: state?.publishableKey,
claimAccountlessKeysUrl: state?.claimUrl,
__internal_bypassMissingPk: true,
} as any);
};

export const ClientClerkProvider = (props: NextClerkProviderProps) => {
if (
mergeNextClerkPropsWithEnv({
...props,
}).publishableKey
) {
return <__ClientClerkProvider {...props} />;
}

return (
<AccountlessCreateKeys>
<__ClientClerkProvider {...props} />
</AccountlessCreateKeys>
);
};
15 changes: 15 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,15 @@
'use client';

import type { AccountlessApplication } from '@clerk/backend';
import type { PropsWithChildren } from 'react';
import { useEffect } from 'react';

import { syncAccountlessKeysAction } from '../server-actions';

export function AccountlessCookieSync(props: PropsWithChildren<AccountlessApplication>) {
useEffect(() => {
void syncAccountlessKeysAction(props);
}, []);

return props.children;
}
33 changes: 33 additions & 0 deletions packages/nextjs/src/app-router/server-actions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
'use server';

import type { AccountlessApplication } from '@clerk/backend';
import { getCookies } from 'ezheaders';
import { redirect, RedirectType } from 'next/navigation';

import { getAccountlessCookieName } from '../server/accountless';
import { createAccountlessKeys } from '../server/accountless-node';

// This function needs to be async as we'd like to support next versions in the range of [14.1.2,14.2.0)
// These versions required 'use server' files to export async methods only. This check was later relaxed
Expand All @@ -9,3 +14,31 @@ import { getCookies } from 'ezheaders';
export async function invalidateCacheAction(): Promise<void> {
void (await getCookies()).delete(`__clerk_invalidate_cache_cookie_${Date.now()}`);
}

export async function syncAccountlessKeysAction(args: AccountlessApplication): Promise<void> {
const { claimUrl, publishableKey, secretKey } = args;
void (await getCookies()).set(getAccountlessCookieName(), 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-accountless', RedirectType.replace);
}

export async function createAccountlessKeysAction(): Promise<null | AccountlessApplication> {
const result = await createAccountlessKeys();

if (!result) {
return null;
}

const { claimUrl, publishableKey, secretKey } = result;

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

return result;
}
94 changes: 94 additions & 0 deletions packages/nextjs/src/server/accountless-node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path';

import type { AccountlessApplication } from '@clerk/backend';

import { createClerkClientWithOptions } from './clerkClient';

const CLERK_HIDDEN = '.clerk';

function updateGitignore() {
const gitignorePath = 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 CLERK_LOCK = 'clerk.lock';
const getClerkPath = () => path.join(process.cwd(), '.clerk', '.tmp', 'accountless.json');

let isCreatingFile = false;

function safeParseClerkFile(): AccountlessApplication | undefined {
try {
const CLERK_PATH = getClerkPath();
let fileAsString;
try {
fileAsString = readFileSync(CLERK_PATH, { encoding: 'utf-8' }) || '{}';
} catch {
fileAsString = '{}';
}
return JSON.parse(fileAsString) as AccountlessApplication;
} catch {
return undefined;
}
}

async function createAccountlessKeys(): Promise<AccountlessApplication | undefined> {
if (isCreatingFile) {
return undefined;
}

if (existsSync(CLERK_LOCK)) {
return undefined;
}

isCreatingFile = true;

writeFileSync(CLERK_LOCK, 'You can delete this file.', {
encoding: 'utf8',
mode: '0777',
flag: 'w',
});

const CLERK_PATH = getClerkPath();
mkdirSync(path.dirname(CLERK_PATH), { recursive: true });
updateGitignore();

const envVarsMap = safeParseClerkFile();

if (envVarsMap?.publishableKey && envVarsMap?.secretKey) {
isCreatingFile = false;
rmSync(CLERK_LOCK, { force: true, recursive: true });
return envVarsMap;
}

/**
* Maybe the server has not restarted yet
*/

const client = createClerkClientWithOptions({});

const accountlessApplication = await client.__experimental_accountlessApplications.createAccountlessApplication();

console.log('--- new keys', accountlessApplication);

writeFileSync(CLERK_PATH, JSON.stringify(accountlessApplication), {
encoding: 'utf8',
mode: '0777',
flag: 'w',
});

rmSync(CLERK_LOCK, { force: true, recursive: true });

isCreatingFile = false;
return accountlessApplication;
}

export { createAccountlessKeys };
Loading
Loading