Skip to content

feat(nextjs,shared,backend,clerk-react): Introduce Protect for authorization (#2170) #2309

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 5 commits into from
Dec 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .changeset/friendly-parrots-nail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
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