Skip to content

Commit 2dc93d4

Browse files
authored
feat(nextjs,shared,backend,clerk-react): Introduce Protect for authorization (#2170) (#2309)
* feat(nextjs,shared,backend,clerk-react): Introduce Protect for authorization (#2170) * fix(types): Avoid using `ts-expect-error` as it may fail for hosting apps * fix(types,nextjs): Improve complex type * fix(types): Typescript v5 cannot infer types correctly * fix(types): Update MembershipRole and OrganizationPermissionKey to not resolve to `any`
1 parent eb94116 commit 2dc93d4

File tree

32 files changed

+450
-225
lines changed

32 files changed

+450
-225
lines changed

.changeset/friendly-parrots-nail.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

.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,8 +1,10 @@
11
import { deprecated } from '@clerk/shared/deprecated';
22
import type {
33
ActClaim,
4-
experimental__CheckAuthorizationWithoutPermission,
4+
CheckAuthorizationWithCustomPermissions,
55
JwtPayload,
6+
OrganizationCustomPermissionKey,
7+
OrganizationCustomRoleKey,
68
ServerGetToken,
79
ServerGetTokenOptions,
810
} from '@clerk/types';
@@ -38,14 +40,12 @@ export type SignedInAuthObject = {
3840
userId: string;
3941
user: User | undefined;
4042
orgId: string | undefined;
41-
orgRole: string | undefined;
43+
orgRole: OrganizationCustomRoleKey | undefined;
4244
orgSlug: string | undefined;
45+
orgPermissions: OrganizationCustomPermissionKey[] | undefined;
4346
organization: Organization | undefined;
4447
getToken: ServerGetToken;
45-
/**
46-
* @experimental The method is experimental and subject to change in future releases.
47-
*/
48-
experimental__has: experimental__CheckAuthorizationWithoutPermission;
48+
has: CheckAuthorizationWithCustomPermissions;
4949
debug: AuthObjectDebug;
5050
};
5151

@@ -59,12 +59,10 @@ export type SignedOutAuthObject = {
5959
orgId: null;
6060
orgRole: null;
6161
orgSlug: null;
62+
orgPermissions: null;
6263
organization: null;
6364
getToken: ServerGetToken;
64-
/**
65-
* @experimental The method is experimental and subject to change in future releases.
66-
*/
67-
experimental__has: experimental__CheckAuthorizationWithoutPermission;
65+
has: CheckAuthorizationWithCustomPermissions;
6866
debug: AuthObjectDebug;
6967
};
7068

@@ -91,6 +89,7 @@ export function signedInAuthObject(
9189
org_id: orgId,
9290
org_role: orgRole,
9391
org_slug: orgSlug,
92+
org_permissions: orgPermissions,
9493
sub: userId,
9594
} = sessionClaims;
9695
const { apiKey, secretKey, apiUrl, apiVersion, token, session, user, organization } = options;
@@ -122,9 +121,10 @@ export function signedInAuthObject(
122121
orgId,
123122
orgRole,
124123
orgSlug,
124+
orgPermissions,
125125
organization,
126126
getToken,
127-
experimental__has: createHasAuthorization({ orgId, orgRole, userId }),
127+
has: createHasAuthorization({ orgId, orgRole, orgPermissions, userId }),
128128
debug: createDebug({ ...options, ...debugData }),
129129
};
130130
}
@@ -144,9 +144,10 @@ export function signedOutAuthObject(debugData?: AuthObjectDebugData): SignedOutA
144144
orgId: null,
145145
orgRole: null,
146146
orgSlug: null,
147+
orgPermissions: null,
147148
organization: null,
148149
getToken: () => Promise.resolve(null),
149-
experimental__has: () => false,
150+
has: () => false,
150151
debug: createDebug(debugData),
151152
};
152153
}
@@ -192,7 +193,7 @@ export function sanitizeAuthObject<T extends Record<any, any>>(authObject: T): T
192193
export const makeAuthObjectSerializable = <T extends Record<string, unknown>>(obj: T): T => {
193194
// remove any non-serializable props from the returned object
194195

195-
const { debug, getToken, experimental__has, ...rest } = obj as unknown as AuthObject;
196+
const { debug, getToken, has, ...rest } = obj as unknown as AuthObject;
196197
return rest as unknown as T;
197198
};
198199

@@ -221,27 +222,30 @@ const createHasAuthorization =
221222
orgId,
222223
orgRole,
223224
userId,
225+
orgPermissions,
224226
}: {
225227
userId: string;
226228
orgId: string | undefined;
227229
orgRole: string | undefined;
228-
}): experimental__CheckAuthorizationWithoutPermission =>
230+
orgPermissions: string[] | undefined;
231+
}): CheckAuthorizationWithCustomPermissions =>
229232
params => {
230-
if (!orgId || !userId) {
233+
if (!params?.permission && !params?.role) {
234+
throw new Error(
235+
'Missing parameters. `has` from `auth` or `getAuth` requires a permission or role key to be passed. Example usage: `has({permission: "org:posts:edit"`',
236+
);
237+
}
238+
239+
if (!orgId || !userId || !orgRole || !orgPermissions) {
231240
return false;
232241
}
233242

234-
if (params.role) {
235-
return orgRole === params.role;
243+
if (params.permission) {
244+
return orgPermissions.includes(params.permission);
236245
}
237246

238-
if (params.some) {
239-
return !!params.some.find(permObj => {
240-
if (permObj.role) {
241-
return orgRole === permObj.role;
242-
}
243-
return false;
244-
});
247+
if (params.role) {
248+
return orgRole === params.role;
245249
}
246250

247251
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,12 +8,12 @@ exports[`public exports should not include a breaking change 1`] = `
88
"ClerkProvider",
99
"CreateOrganization",
1010
"EmailLinkErrorCode",
11-
"Experimental__Gate",
1211
"MagicLinkErrorCode",
1312
"MultisessionAppSupport",
1413
"OrganizationList",
1514
"OrganizationProfile",
1615
"OrganizationSwitcher",
16+
"Protect",
1717
"RedirectToCreateOrganization",
1818
"RedirectToOrganizationProfile",
1919
"RedirectToSignIn",

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

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {
66
MembershipRole,
77
OrganizationMembershipJSON,
88
OrganizationMembershipResource,
9-
OrganizationPermission,
9+
OrganizationPermissionKey,
1010
} from '@clerk/types';
1111

1212
import { unixEpochToDate } from '../../utils/date';
@@ -18,12 +18,7 @@ export class OrganizationMembership extends BaseResource implements Organization
1818
publicMetadata: OrganizationMembershipPublicMetadata = {};
1919
publicUserData!: PublicUserData;
2020
organization!: Organization;
21-
/**
22-
* @experimental The property is experimental and subject to change in future releases.
23-
*/
24-
// Adding (string & {}) allows for getting eslint autocomplete but also accepts any string
25-
// eslint-disable-next-line
26-
permissions: (OrganizationPermission | (string & {}))[] = [];
21+
permissions: OrganizationPermissionKey[] = [];
2722
role!: MembershipRole;
2823
createdAt!: Date;
2924
updatedAt!: Date;

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
@@ -76,10 +76,7 @@ export class Session extends BaseResource implements SessionResource {
7676
});
7777
};
7878

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

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

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

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,62 @@
1-
import type { CheckAuthorization } from '@clerk/types';
1+
import type { CheckAuthorization, MembershipRole, OrganizationPermissionKey } from '@clerk/types';
22
import type { ComponentType, PropsWithChildren, ReactNode } from 'react';
33
import React, { useEffect } from 'react';
44

55
import { useCoreSession } from '../contexts';
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
}
1430
>;
1531

1632
export const useGate = (params: GateParams) => {
17-
const { experimental__checkAuthorization } = useCoreSession();
33+
const { checkAuthorization, id } = useCoreSession();
34+
35+
if (!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(checkAuthorization)) {
44+
return { isAuthorizedUser: true };
45+
}
46+
return { isAuthorizedUser: false };
47+
}
1848

1949
return {
20-
isAuthorizedUser: experimental__checkAuthorization(params),
50+
isAuthorizedUser: 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/components/OrganizationProfile/OrganizationProfileNavbar.tsx

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

15-
const { isAuthorizedUser: allowMembersRoute } = useGate({
16-
some: [
17-
{
15+
const { isAuthorizedUser: allowMembersRoute } = useGate(
16+
has =>
17+
has({
1818
permission: 'org:sys_memberships:read',
19-
},
20-
{
21-
permission: 'org:sys_memberships:manage',
22-
},
23-
],
24-
});
19+
}) || has({ permission: 'org:sys_memberships:manage' }),
20+
);
2521

2622
if (!organization) {
2723
return null;

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

Lines changed: 4 additions & 2 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,7 +130,9 @@ 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' }]}
133+
condition={has =>
134+
has({ permission: 'org:sys_memberships:read' }) || has({ permission: 'org:sys_memberships:manage' })
135+
}
134136
redirectTo={isSettingsPageRoot ? '../' : './organization-settings'}
135137
>
136138
<OrganizationMembers />

packages/clerk-js/src/ui/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)