Skip to content
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

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

Merged
merged 26 commits into from
Dec 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5f5bf7e
feat(nextjs,shared,backend,clerk-react): Support permissions in Gate
panteliselef Oct 23, 2023
9893370
chore(types,backend,clerk-react): Create type for OrganizationCustomP…
panteliselef Nov 20, 2023
fb1bed5
chore(types,backend,clerk-react): Create type for custom roles
panteliselef Nov 20, 2023
153140b
chore(types,backend,clerk-react): Add changeset
panteliselef Nov 20, 2023
ca8c64d
chore(types,backend,clerk-react): Add comments
panteliselef Nov 20, 2023
be5aaee
chore(types,nextjs): Remove custom types
panteliselef Nov 24, 2023
bf3cf34
fix(clerk-react): Missing `some` support for has in useAuth
panteliselef Nov 24, 2023
1a112db
chore(types,clerk-react): Use OrganizationCustomPermission for permis…
panteliselef Nov 24, 2023
a91dc74
chore(nextjs): Drop redirect from RSC `<Gate/>`
panteliselef Nov 24, 2023
a55afe8
feat(types,nextjs,clerk-react,backend): Rename Gate to Protect
panteliselef Dec 3, 2023
6984390
feat(nextjs): Introduce `auth().protect()` for App Router
panteliselef Dec 3, 2023
367a3b3
chore(types): Add `Key` prefix to OrganizationCustomPermission
panteliselef Dec 4, 2023
4dc9b9f
chore(nextjs): Remove duplicate types
panteliselef Dec 4, 2023
7a218c7
chore(nextjs): Minor improvements in readability
panteliselef Dec 4, 2023
3f154ad
chore(nextjs): Mark protect utility as experimental for Nextjs
panteliselef Dec 4, 2023
d4f7d49
chore(nextjs): Minor improvements
panteliselef Dec 4, 2023
1d323ad
fix(nextjs,clerk-react,backend): Utility `has` is undefined when user…
panteliselef Dec 5, 2023
a8d61dc
fix(clerk-react): Utility `has` returns false when user isLoaded is t…
panteliselef Dec 6, 2023
48d64c5
chore(clerk-react,nextjs): Improve comments
panteliselef Dec 6, 2023
e3a4c23
fix(clerk-react): Eliminate flickering of fallback for CSR applications
panteliselef Dec 6, 2023
819b6a0
feat(types): Allow overriding of types for custom roles and permissions
panteliselef Dec 5, 2023
3c6e17e
chore(repo): Update changeset file
panteliselef Nov 20, 2023
64ad323
fix(types): `MembershipRole` will include custom roles if applicable
panteliselef Dec 7, 2023
87ea852
chore(nextjs): Improve readability of conditionals
panteliselef Dec 11, 2023
584c71c
Revert "fix(nextjs,clerk-react,backend): Utility `has` is undefined w…
panteliselef Dec 11, 2023
79fff5a
fix(clerk-js,types): Remove `experimental` from checkAuthorization
panteliselef Dec 11, 2023
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
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,7 +1,9 @@
import type {
ActClaim,
experimental__CheckAuthorizationWithoutPermission,
CheckAuthorizationWithCustomPermissions,
JwtPayload,
OrganizationCustomPermissionKey,
OrganizationCustomRoleKey,
ServerGetToken,
ServerGetTokenOptions,
} from '@clerk/types';
Expand All @@ -28,14 +30,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 @@ -49,12 +49,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 @@ -80,6 +78,7 @@ export function signedInAuthObject(
org_id: orgId,
org_role: orgRole,
org_slug: orgSlug,
org_permissions: orgPermissions,
sub: userId,
} = sessionClaims;
const { secretKey, apiUrl, apiVersion, token, session, user, organization } = options;
Expand All @@ -105,9 +104,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 @@ -123,9 +123,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 @@ -171,7 +172,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 @@ -200,27 +201,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,11 +8,11 @@ exports[`public exports should not include a breaking change 1`] = `
"ClerkProvider",
"CreateOrganization",
"EmailLinkErrorCode",
"Experimental__Gate",
"MultisessionAppSupport",
"OrganizationList",
"OrganizationProfile",
"OrganizationSwitcher",
"Protect",
"RedirectToCreateOrganization",
"RedirectToOrganizationProfile",
"RedirectToSignIn",
Expand Down
10 changes: 3 additions & 7 deletions packages/clerk-js/src/core/resources/OrganizationMembership.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import type {
Autocomplete,
ClerkPaginatedResponse,
ClerkResourceReloadParams,
GetUserOrganizationMembershipParams,
MembershipRole,
OrganizationMembershipJSON,
OrganizationMembershipResource,
OrganizationPermission,
OrganizationPermissionKey,
} from '@clerk/types';

import { unixEpochToDate } from '../../utils/date';
Expand All @@ -18,10 +17,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.
*/
permissions: Autocomplete<OrganizationPermission>[] = [];
permissions: OrganizationPermissionKey[] = [];
role!: MembershipRole;
createdAt!: Date;
updatedAt!: Date;
Expand All @@ -37,7 +33,7 @@ export class OrganizationMembership extends BaseResource implements Organization
method: 'GET',
// `paginated` is used in some legacy endpoints to support clerk paginated responses
// The parameter will be dropped in FAPI v2
search: convertPageToOffset({ ...retrieveMembershipsParams, paginated: true }) as any,
search: convertPageToOffset({ ...retrieveMembershipsParams, paginated: true }),
})
.then(res => {
if (!res?.response) {
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 @@ -75,10 +75,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 @@ -103,18 +100,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
42 changes: 37 additions & 5 deletions packages/clerk-js/src/ui.retheme/common/Gate.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,29 @@
import { useSession } from '@clerk/shared/react';
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 { 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;
}
Expand All @@ -16,15 +32,31 @@ type GateProps = PropsWithChildren<
export const useGate = (params: GateParams) => {
const { session } = useSession();

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

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

return {
isAuthorizedUser: session?.experimental__checkAuthorization(params),
isAuthorizedUser: session?.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 @@ -14,16 +14,12 @@ export const OrganizationProfileNavbar = (
const { organization } = useOrganization();
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,8 +130,10 @@ export const OrganizationProfileRoutes = (props: PropsOfComponent<typeof Profile
</Route>
<Route index>
<Gate
some={[{ permission: 'org:sys_memberships:read' }, { permission: 'org:sys_memberships:manage' }]}
redirectTo='./organization-settings'
condition={has =>
has({ permission: 'org:sys_memberships:read' }) || has({ permission: 'org:sys_memberships:manage' })
}
redirectTo={isSettingsPageRoot ? '../' : './organization-settings'}
>
<OrganizationMembers />
</Gate>
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/src/ui.retheme/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
Loading