Skip to content

Commit

Permalink
feat(nextjs,shared,backend,clerk-react): Introduce Protect for author…
Browse files Browse the repository at this point in the history
…ization (#2170)
  • Loading branch information
panteliselef committed Dec 11, 2023
1 parent 7657cd3 commit 508071b
Show file tree
Hide file tree
Showing 31 changed files with 439 additions and 225 deletions.
20 changes: 20 additions & 0 deletions .changeset/short-eagles-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@clerk/chrome-extension': minor
'@clerk/clerk-js': minor
'@clerk/backend': minor
'@clerk/nextjs': minor
'@clerk/clerk-react': minor
'@clerk/types': minor
---

Introduce Protect for authorization.
Changes in public APIs:
- Rename Gate to Protect
- Support for permission checks. (Previously only roles could be used)
- Remove the `experimental` tags and prefixes
- 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 case behaves as `<SignedIn>`, if no authorization props are passed.
- `has` will throw an error if neither `permission` or `role` is passed.
- `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.
- 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
52 changes: 28 additions & 24 deletions packages/backend/src/tokens/authObjects.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { deprecated } from '@clerk/shared/deprecated';
import type {
ActClaim,
experimental__CheckAuthorizationWithoutPermission,
CheckAuthorizationWithCustomPermissions,
JwtPayload,
OrganizationCustomPermissionKey,
OrganizationCustomRoleKey,
ServerGetToken,
ServerGetTokenOptions,
} from '@clerk/types';
Expand Down Expand Up @@ -38,14 +40,12 @@ export type SignedInAuthObject = {
userId: string;
user: User | undefined;
orgId: string | undefined;
orgRole: string | undefined;
orgRole: OrganizationCustomRoleKey | undefined;
orgSlug: string | undefined;
orgPermissions: OrganizationCustomPermissionKey[] | undefined;
organization: Organization | undefined;
getToken: ServerGetToken;
/**
* @experimental The method is experimental and subject to change in future releases.
*/
experimental__has: experimental__CheckAuthorizationWithoutPermission;
has: CheckAuthorizationWithCustomPermissions;
debug: AuthObjectDebug;
};

Expand All @@ -59,12 +59,10 @@ export type SignedOutAuthObject = {
orgId: null;
orgRole: null;
orgSlug: null;
orgPermissions: null;
organization: null;
getToken: ServerGetToken;
/**
* @experimental The method is experimental and subject to change in future releases.
*/
experimental__has: experimental__CheckAuthorizationWithoutPermission;
has: CheckAuthorizationWithCustomPermissions;
debug: AuthObjectDebug;
};

Expand All @@ -91,6 +89,7 @@ export function signedInAuthObject(
org_id: orgId,
org_role: orgRole,
org_slug: orgSlug,
org_permissions: orgPermissions,
sub: userId,
} = sessionClaims;
const { apiKey, secretKey, apiUrl, apiVersion, token, session, user, organization } = options;
Expand Down Expand Up @@ -122,9 +121,10 @@ export function signedInAuthObject(
orgId,
orgRole,
orgSlug,
orgPermissions,
organization,
getToken,
experimental__has: createHasAuthorization({ orgId, orgRole, userId }),
has: createHasAuthorization({ orgId, orgRole, orgPermissions, userId }),
debug: createDebug({ ...options, ...debugData }),
};
}
Expand All @@ -144,9 +144,10 @@ export function signedOutAuthObject(debugData?: AuthObjectDebugData): SignedOutA
orgId: null,
orgRole: null,
orgSlug: null,
orgPermissions: null,
organization: null,
getToken: () => Promise.resolve(null),
experimental__has: () => false,
has: () => false,
debug: createDebug(debugData),
};
}
Expand Down Expand Up @@ -192,7 +193,7 @@ export function sanitizeAuthObject<T extends Record<any, any>>(authObject: T): T
export const makeAuthObjectSerializable = <T extends Record<string, unknown>>(obj: T): T => {
// remove any non-serializable props from the returned object

const { debug, getToken, experimental__has, ...rest } = obj as unknown as AuthObject;
const { debug, getToken, has, ...rest } = obj as unknown as AuthObject;
return rest as unknown as T;
};

Expand Down Expand Up @@ -221,27 +222,30 @@ const createHasAuthorization =
orgId,
orgRole,
userId,
orgPermissions,
}: {
userId: string;
orgId: string | undefined;
orgRole: string | undefined;
}): experimental__CheckAuthorizationWithoutPermission =>
orgPermissions: string[] | undefined;
}): CheckAuthorizationWithCustomPermissions =>
params => {
if (!orgId || !userId) {
if (!params?.permission && !params?.role) {
throw new Error(
'Missing parameters. `has` from `auth` or `getAuth` requires a permission or role key to be passed. Example usage: `has({permission: "org:posts:edit"`',
);
}

if (!orgId || !userId || !orgRole || !orgPermissions) {
return false;
}

if (params.role) {
return orgRole === params.role;
if (params.permission) {
return orgPermissions.includes(params.permission);
}

if (params.some) {
return !!params.some.find(permObj => {
if (permObj.role) {
return orgRole === permObj.role;
}
return false;
});
if (params.role) {
return orgRole === params.role;
}

return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ exports[`public exports should not include a breaking change 1`] = `
"ClerkProvider",
"CreateOrganization",
"EmailLinkErrorCode",
"Experimental__Gate",
"MagicLinkErrorCode",
"MultisessionAppSupport",
"OrganizationList",
"OrganizationProfile",
"OrganizationSwitcher",
"Protect",
"RedirectToCreateOrganization",
"RedirectToOrganizationProfile",
"RedirectToSignIn",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {
MembershipRole,
OrganizationMembershipJSON,
OrganizationMembershipResource,
OrganizationPermission,
OrganizationPermissionKey,
} from '@clerk/types';

import { unixEpochToDate } from '../../utils/date';
Expand All @@ -18,12 +18,7 @@ export class OrganizationMembership extends BaseResource implements Organization
publicMetadata: OrganizationMembershipPublicMetadata = {};
publicUserData!: PublicUserData;
organization!: Organization;
/**
* @experimental The property is experimental and subject to change in future releases.
*/
// Adding (string & {}) allows for getting eslint autocomplete but also accepts any string
// eslint-disable-next-line
permissions: (OrganizationPermission | (string & {}))[] = [];
permissions: OrganizationPermissionKey[] = [];
role!: MembershipRole;
createdAt!: Date;
updatedAt!: Date;
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/resources/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ describe('Session', () => {
updated_at: new Date().getTime(),
} as SessionJSON);

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

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

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

expect(isAuthorized).toBe(false);
});
Expand Down
17 changes: 1 addition & 16 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,7 @@ export class Session extends BaseResource implements SessionResource {
});
};

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

if (params.some) {
return !!params.some.find(permObj => {
if (permObj.permission) {
return activeOrganizationPermissions.includes(permObj.permission);
}
if (permObj.role) {
return activeOrganizationRole === permObj.role;
}
return false;
});
}

return false;
};

Expand Down
44 changes: 38 additions & 6 deletions packages/clerk-js/src/ui/common/Gate.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,62 @@
import type { CheckAuthorization } from '@clerk/types';
import type { CheckAuthorization, MembershipRole, OrganizationPermissionKey } from '@clerk/types';
import type { ComponentType, PropsWithChildren, ReactNode } from 'react';
import React, { useEffect } from 'react';

import { useCoreSession } from '../contexts';
import { useRouter } from '../router';

type GateParams = Parameters<CheckAuthorization>[0];
type GateParams = Parameters<CheckAuthorization>[0] | ((has: CheckAuthorization) => boolean);
type GateProps = PropsWithChildren<
GateParams & {
(
| {
condition?: never;
role: MembershipRole;
permission?: never;
}
| {
condition?: never;
role?: never;
permission: OrganizationPermissionKey;
}
| {
condition: (has: CheckAuthorization) => boolean;
role?: never;
permission?: never;
}
) & {
fallback?: ReactNode;
redirectTo?: string;
}
>;

export const useGate = (params: GateParams) => {
const { experimental__checkAuthorization } = useCoreSession();
const { checkAuthorization, id } = useCoreSession();

if (!id) {
return { isAuthorizedUser: false };
}

/**
* if a function is passed and returns false then throw not found
*/
if (typeof params === 'function') {
if (params(checkAuthorization)) {
return { isAuthorizedUser: true };
}
return { isAuthorizedUser: false };
}

return {
isAuthorizedUser: experimental__checkAuthorization(params),
isAuthorizedUser: checkAuthorization(params),
};
};

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

const { isAuthorizedUser } = useGate(restAuthorizedParams);
const { isAuthorizedUser } = useGate(
typeof restAuthorizedParams.condition === 'function' ? restAuthorizedParams.condition : restAuthorizedParams,
);

const { navigate } = useRouter();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,12 @@ export const OrganizationProfileNavbar = (
const { organization } = useCoreOrganization();
const { pages } = useOrganizationProfileContext();

const { isAuthorizedUser: allowMembersRoute } = useGate({
some: [
{
const { isAuthorizedUser: allowMembersRoute } = useGate(
has =>
has({
permission: 'org:sys_memberships:read',
},
{
permission: 'org:sys_memberships:manage',
},
],
});
}) || has({ permission: 'org:sys_memberships:manage' }),
);

if (!organization) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export const OrganizationProfileRoutes = (props: PropsOfComponent<typeof Profile
</Route>
<Route path=':id'>
<Gate
permission={'org:sys_domains:manage'}
permission='org:sys_domains:manage'
redirectTo='../../'
>
<VerifiedDomainPage />
Expand Down Expand Up @@ -130,7 +130,9 @@ export const OrganizationProfileRoutes = (props: PropsOfComponent<typeof Profile
</Route>
<Route index>
<Gate
some={[{ permission: 'org:sys_memberships:read' }, { permission: 'org:sys_memberships:manage' }]}
condition={has =>
has({ permission: 'org:sys_memberships:read' }) || has({ permission: 'org:sys_memberships:manage' })
}
redirectTo={isSettingsPageRoot ? '../' : './organization-settings'}
>
<OrganizationMembers />
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/src/ui/utils/test/mockHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepJestMocked<LoadedClerk
mockMethodsOf(clerk.client.signUp);
clerk.client.sessions.forEach(session => {
mockMethodsOf(session, {
exclude: ['experimental__checkAuthorization'],
exclude: ['checkAuthorization'],
});
mockMethodsOf(session.user);
session.user?.emailAddresses.forEach(m => mockMethodsOf(m));
Expand Down
Loading

0 comments on commit 508071b

Please sign in to comment.