Skip to content

Commit 46040a2

Browse files
authored
feat(nextjs,shared,backend,clerk-react): Introduce Protect for authorization (#2170)
* feat(nextjs,shared,backend,clerk-react): Support permissions in Gate * chore(types,backend,clerk-react): Create type for OrganizationCustomPermissions * chore(types,backend,clerk-react): Create type for custom roles * chore(types,backend,clerk-react): Add changeset * chore(types,backend,clerk-react): Add comments * chore(types,nextjs): Remove custom types * fix(clerk-react): Missing `some` support for has in useAuth * chore(types,clerk-react): Use OrganizationCustomPermission for permissions in ssr * chore(nextjs): Drop redirect from RSC `<Gate/>` * feat(types,nextjs,clerk-react,backend): Rename Gate to Protect - Drop `some` from the `has` utility and Protect. Protect now accepts a `condition` prop where a function is expected with the `has` being exposed as the param. - Protect can now be used without required props. In this chae behaves as `<SignedIn>` if no authorization props are passed. - `has` will throw an error if neither `permission` or `role` is passed. * feat(nextjs): Introduce `auth().protect()` for App Router Allow per page protection in app router. This utility will automatically throw a 404 error if user is not authorized or authenticated. When `auth().protect()` is called - inside a page or layout file it will render the nearest `not-found` component set by the developer - inside a route handler it will return empty response body with a 404 status code * chore(types): Add `Key` prefix to OrganizationCustomPermission * chore(nextjs): Remove duplicate types * chore(nextjs): Minor improvements in readability * chore(nextjs): Mark protect utility as experimental for Nextjs * chore(nextjs): Minor improvements * fix(nextjs,clerk-react,backend): Utility `has` is undefined when user is signed out * fix(clerk-react): Utility `has` returns false when user isLoaded is true and no user or org * chore(clerk-react,nextjs): Improve comments * fix(clerk-react): Eliminate flickering of fallback for CSR applications * feat(types): Allow overriding of types for custom roles and permissions * chore(repo): Update changeset file * fix(types): `MembershipRole` will include custom roles if applicable * chore(nextjs): Improve readability of conditionals * Revert "fix(nextjs,clerk-react,backend): Utility `has` is undefined when user is signed out" This reverts commit cf736cc * fix(clerk-js,types): Remove `experimental` from checkAuthorization
1 parent 4eed423 commit 46040a2

34 files changed

+461
-222
lines changed

.changeset/short-eagles-search.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@clerk/chrome-extension': minor
3+
'@clerk/clerk-js': minor
4+
'@clerk/backend': minor
5+
'@clerk/nextjs': minor
6+
'@clerk/clerk-react': minor
7+
'@clerk/types': minor
8+
---
9+
10+
Introduce Protect for authorization.
11+
Changes in public APIs:
12+
- Rename Gate to Protect
13+
- Support for permission checks. (Previously only roles could be used)
14+
- Remove the `experimental` tags and prefixes
15+
- Drop `some` from the `has` utility and Protect. Protect now accepts a `condition` prop where a function is expected with the `has` being exposed as the param.
16+
- Protect can now be used without required props. In this case behaves as `<SignedIn>`, if no authorization props are passed.
17+
- `has` will throw an error if neither `permission` or `role` is passed.
18+
- `auth().protect()` for Nextjs App Router. Allow per page protection in app router. This utility will automatically throw a 404 error if user is not authorized or authenticated.
19+
- inside a page or layout file it will render the nearest `not-found` component set by the developer
20+
- inside a route handler it will return empty response body with a 404 status code

packages/backend/src/tokens/authObjects.ts

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type {
22
ActClaim,
3-
experimental__CheckAuthorizationWithoutPermission,
3+
CheckAuthorizationWithCustomPermissions,
44
JwtPayload,
5+
OrganizationCustomPermissionKey,
6+
OrganizationCustomRoleKey,
57
ServerGetToken,
68
ServerGetTokenOptions,
79
} from '@clerk/types';
@@ -28,14 +30,12 @@ export type SignedInAuthObject = {
2830
userId: string;
2931
user: User | undefined;
3032
orgId: string | undefined;
31-
orgRole: string | undefined;
33+
orgRole: OrganizationCustomRoleKey | undefined;
3234
orgSlug: string | undefined;
35+
orgPermissions: OrganizationCustomPermissionKey[] | undefined;
3336
organization: Organization | undefined;
3437
getToken: ServerGetToken;
35-
/**
36-
* @experimental The method is experimental and subject to change in future releases.
37-
*/
38-
experimental__has: experimental__CheckAuthorizationWithoutPermission;
38+
has: CheckAuthorizationWithCustomPermissions;
3939
debug: AuthObjectDebug;
4040
};
4141

@@ -49,12 +49,10 @@ export type SignedOutAuthObject = {
4949
orgId: null;
5050
orgRole: null;
5151
orgSlug: null;
52+
orgPermissions: null;
5253
organization: null;
5354
getToken: ServerGetToken;
54-
/**
55-
* @experimental The method is experimental and subject to change in future releases.
56-
*/
57-
experimental__has: experimental__CheckAuthorizationWithoutPermission;
55+
has: CheckAuthorizationWithCustomPermissions;
5856
debug: AuthObjectDebug;
5957
};
6058

@@ -80,6 +78,7 @@ export function signedInAuthObject(
8078
org_id: orgId,
8179
org_role: orgRole,
8280
org_slug: orgSlug,
81+
org_permissions: orgPermissions,
8382
sub: userId,
8483
} = sessionClaims;
8584
const { secretKey, apiUrl, apiVersion, token, session, user, organization } = options;
@@ -105,9 +104,10 @@ export function signedInAuthObject(
105104
orgId,
106105
orgRole,
107106
orgSlug,
107+
orgPermissions,
108108
organization,
109109
getToken,
110-
experimental__has: createHasAuthorization({ orgId, orgRole, userId }),
110+
has: createHasAuthorization({ orgId, orgRole, orgPermissions, userId }),
111111
debug: createDebug({ ...options, ...debugData }),
112112
};
113113
}
@@ -123,9 +123,10 @@ export function signedOutAuthObject(debugData?: AuthObjectDebugData): SignedOutA
123123
orgId: null,
124124
orgRole: null,
125125
orgSlug: null,
126+
orgPermissions: null,
126127
organization: null,
127128
getToken: () => Promise.resolve(null),
128-
experimental__has: () => false,
129+
has: () => false,
129130
debug: createDebug(debugData),
130131
};
131132
}
@@ -171,7 +172,7 @@ export function sanitizeAuthObject<T extends Record<any, any>>(authObject: T): T
171172
export const makeAuthObjectSerializable = <T extends Record<string, unknown>>(obj: T): T => {
172173
// remove any non-serializable props from the returned object
173174

174-
const { debug, getToken, experimental__has, ...rest } = obj as unknown as AuthObject;
175+
const { debug, getToken, has, ...rest } = obj as unknown as AuthObject;
175176
return rest as unknown as T;
176177
};
177178

@@ -200,27 +201,30 @@ const createHasAuthorization =
200201
orgId,
201202
orgRole,
202203
userId,
204+
orgPermissions,
203205
}: {
204206
userId: string;
205207
orgId: string | undefined;
206208
orgRole: string | undefined;
207-
}): experimental__CheckAuthorizationWithoutPermission =>
209+
orgPermissions: string[] | undefined;
210+
}): CheckAuthorizationWithCustomPermissions =>
208211
params => {
209-
if (!orgId || !userId) {
212+
if (!params?.permission && !params?.role) {
213+
throw new Error(
214+
'Missing parameters. `has` from `auth` or `getAuth` requires a permission or role key to be passed. Example usage: `has({permission: "org:posts:edit"`',
215+
);
216+
}
217+
218+
if (!orgId || !userId || !orgRole || !orgPermissions) {
210219
return false;
211220
}
212221

213-
if (params.role) {
214-
return orgRole === params.role;
222+
if (params.permission) {
223+
return orgPermissions.includes(params.permission);
215224
}
216225

217-
if (params.some) {
218-
return !!params.some.find(permObj => {
219-
if (permObj.role) {
220-
return orgRole === permObj.role;
221-
}
222-
return false;
223-
});
226+
if (params.role) {
227+
return orgRole === params.role;
224228
}
225229

226230
return false;

packages/chrome-extension/src/__snapshots__/exports.test.ts.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ exports[`public exports should not include a breaking change 1`] = `
88
"ClerkProvider",
99
"CreateOrganization",
1010
"EmailLinkErrorCode",
11-
"Experimental__Gate",
1211
"MultisessionAppSupport",
1312
"OrganizationList",
1413
"OrganizationProfile",
1514
"OrganizationSwitcher",
15+
"Protect",
1616
"RedirectToCreateOrganization",
1717
"RedirectToOrganizationProfile",
1818
"RedirectToSignIn",

packages/clerk-js/src/core/resources/OrganizationMembership.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import type {
2-
Autocomplete,
32
ClerkPaginatedResponse,
43
ClerkResourceReloadParams,
54
GetUserOrganizationMembershipParams,
65
MembershipRole,
76
OrganizationMembershipJSON,
87
OrganizationMembershipResource,
9-
OrganizationPermission,
8+
OrganizationPermissionKey,
109
} from '@clerk/types';
1110

1211
import { unixEpochToDate } from '../../utils/date';
@@ -18,10 +17,7 @@ export class OrganizationMembership extends BaseResource implements Organization
1817
publicMetadata: OrganizationMembershipPublicMetadata = {};
1918
publicUserData!: PublicUserData;
2019
organization!: Organization;
21-
/**
22-
* @experimental The property is experimental and subject to change in future releases.
23-
*/
24-
permissions: Autocomplete<OrganizationPermission>[] = [];
20+
permissions: OrganizationPermissionKey[] = [];
2521
role!: MembershipRole;
2622
createdAt!: Date;
2723
updatedAt!: Date;
@@ -37,7 +33,7 @@ export class OrganizationMembership extends BaseResource implements Organization
3733
method: 'GET',
3834
// `paginated` is used in some legacy endpoints to support clerk paginated responses
3935
// The parameter will be dropped in FAPI v2
40-
search: convertPageToOffset({ ...retrieveMembershipsParams, paginated: true }) as any,
36+
search: convertPageToOffset({ ...retrieveMembershipsParams, paginated: true }),
4137
})
4238
.then(res => {
4339
if (!res?.response) {

packages/clerk-js/src/core/resources/Session.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ describe('Session', () => {
7373
updated_at: new Date().getTime(),
7474
} as SessionJSON);
7575

76-
const isAuthorized = await session.experimental__checkAuthorization({ permission: 'org:sys_profile:delete' });
76+
const isAuthorized = await session.checkAuthorization({ permission: 'org:sys_profile:delete' });
7777

7878
expect(isAuthorized).toBe(true);
7979
});
@@ -93,7 +93,7 @@ describe('Session', () => {
9393
updated_at: new Date().getTime(),
9494
} as SessionJSON);
9595

96-
const isAuthorized = await session.experimental__checkAuthorization({ permission: 'org:sys_profile:delete' });
96+
const isAuthorized = await session.checkAuthorization({ permission: 'org:sys_profile:delete' });
9797

9898
expect(isAuthorized).toBe(false);
9999
});

packages/clerk-js/src/core/resources/Session.ts

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,7 @@ export class Session extends BaseResource implements SessionResource {
7575
});
7676
};
7777

78-
/**
79-
* @experimental The method is experimental and subject to change in future releases.
80-
*/
81-
experimental__checkAuthorization: CheckAuthorization = params => {
78+
checkAuthorization: CheckAuthorization = params => {
8279
// if there is no active organization user can not be authorized
8380
if (!this.lastActiveOrganizationId || !this.user) {
8481
return false;
@@ -103,18 +100,6 @@ export class Session extends BaseResource implements SessionResource {
103100
return activeOrganizationRole === params.role;
104101
}
105102

106-
if (params.some) {
107-
return !!params.some.find(permObj => {
108-
if (permObj.permission) {
109-
return activeOrganizationPermissions.includes(permObj.permission);
110-
}
111-
if (permObj.role) {
112-
return activeOrganizationRole === permObj.role;
113-
}
114-
return false;
115-
});
116-
}
117-
118103
return false;
119104
};
120105

packages/clerk-js/src/ui.retheme/common/Gate.tsx

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,29 @@
11
import { useSession } from '@clerk/shared/react';
2-
import type { CheckAuthorization } from '@clerk/types';
2+
import type { CheckAuthorization, MembershipRole, OrganizationPermissionKey } from '@clerk/types';
33
import type { ComponentType, PropsWithChildren, ReactNode } from 'react';
44
import React, { useEffect } from 'react';
55

66
import { useRouter } from '../router';
77

8-
type GateParams = Parameters<CheckAuthorization>[0];
8+
type GateParams = Parameters<CheckAuthorization>[0] | ((has: CheckAuthorization) => boolean);
99
type GateProps = PropsWithChildren<
10-
GateParams & {
10+
(
11+
| {
12+
condition?: never;
13+
role: MembershipRole;
14+
permission?: never;
15+
}
16+
| {
17+
condition?: never;
18+
role?: never;
19+
permission: OrganizationPermissionKey;
20+
}
21+
| {
22+
condition: (has: CheckAuthorization) => boolean;
23+
role?: never;
24+
permission?: never;
25+
}
26+
) & {
1127
fallback?: ReactNode;
1228
redirectTo?: string;
1329
}
@@ -16,15 +32,31 @@ type GateProps = PropsWithChildren<
1632
export const useGate = (params: GateParams) => {
1733
const { session } = useSession();
1834

35+
if (!session?.id) {
36+
return { isAuthorizedUser: false };
37+
}
38+
39+
/**
40+
* if a function is passed and returns false then throw not found
41+
*/
42+
if (typeof params === 'function') {
43+
if (params(session.checkAuthorization)) {
44+
return { isAuthorizedUser: true };
45+
}
46+
return { isAuthorizedUser: false };
47+
}
48+
1949
return {
20-
isAuthorizedUser: session?.experimental__checkAuthorization(params),
50+
isAuthorizedUser: session?.checkAuthorization(params),
2151
};
2252
};
2353

2454
export const Gate = (gateProps: GateProps) => {
2555
const { children, fallback, redirectTo, ...restAuthorizedParams } = gateProps;
2656

27-
const { isAuthorizedUser } = useGate(restAuthorizedParams);
57+
const { isAuthorizedUser } = useGate(
58+
typeof restAuthorizedParams.condition === 'function' ? restAuthorizedParams.condition : restAuthorizedParams,
59+
);
2860

2961
const { navigate } = useRouter();
3062

packages/clerk-js/src/ui.retheme/components/OrganizationProfile/OrganizationProfileNavbar.tsx

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,12 @@ export const OrganizationProfileNavbar = (
1414
const { organization } = useOrganization();
1515
const { pages } = useOrganizationProfileContext();
1616

17-
const { isAuthorizedUser: allowMembersRoute } = useGate({
18-
some: [
19-
{
17+
const { isAuthorizedUser: allowMembersRoute } = useGate(
18+
has =>
19+
has({
2020
permission: 'org:sys_memberships:read',
21-
},
22-
{
23-
permission: 'org:sys_memberships:manage',
24-
},
25-
],
26-
});
21+
}) || has({ permission: 'org:sys_memberships:manage' }),
22+
);
2723

2824
if (!organization) {
2925
return null;

packages/clerk-js/src/ui.retheme/components/OrganizationProfile/OrganizationProfileRoutes.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export const OrganizationProfileRoutes = (props: PropsOfComponent<typeof Profile
7777
</Route>
7878
<Route path=':id'>
7979
<Gate
80-
permission={'org:sys_domains:manage'}
80+
permission='org:sys_domains:manage'
8181
redirectTo='../../'
8282
>
8383
<VerifiedDomainPage />
@@ -130,8 +130,10 @@ export const OrganizationProfileRoutes = (props: PropsOfComponent<typeof Profile
130130
</Route>
131131
<Route index>
132132
<Gate
133-
some={[{ permission: 'org:sys_memberships:read' }, { permission: 'org:sys_memberships:manage' }]}
134-
redirectTo='./organization-settings'
133+
condition={has =>
134+
has({ permission: 'org:sys_memberships:read' }) || has({ permission: 'org:sys_memberships:manage' })
135+
}
136+
redirectTo={isSettingsPageRoot ? '../' : './organization-settings'}
135137
>
136138
<OrganizationMembers />
137139
</Gate>

packages/clerk-js/src/ui.retheme/utils/test/mockHelpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepJestMocked<LoadedClerk
3535
mockMethodsOf(clerk.client.signUp);
3636
clerk.client.sessions.forEach(session => {
3737
mockMethodsOf(session, {
38-
exclude: ['experimental__checkAuthorization'],
38+
exclude: ['checkAuthorization'],
3939
});
4040
mockMethodsOf(session.user);
4141
session.user?.emailAddresses.forEach(m => mockMethodsOf(m));

0 commit comments

Comments
 (0)