diff --git a/.changeset/fair-bobcats-pull.md b/.changeset/fair-bobcats-pull.md new file mode 100644 index 00000000000..8ae6aac38d3 --- /dev/null +++ b/.changeset/fair-bobcats-pull.md @@ -0,0 +1,9 @@ +--- +'@clerk/backend': patch +'@clerk/types': patch +'@clerk/nextjs': patch +--- + +Add type-level validation to prevent server-side usage of system permissions + +System permissions (e.g., `org:sys_domains:manage`) are intentionally excluded from session claims to maintain reasonable JWT sizes. For more information, refer to our docs: https://clerk.com/docs/organizations/roles-permissions#system-permissions diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index e24df5830c6..a0b967cb4df 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -1,7 +1,7 @@ import { createCheckAuthorization } from '@clerk/shared/authorization'; import type { ActClaim, - CheckAuthorizationWithCustomPermissions, + CheckAuthorizationFromSessionClaims, JwtPayload, OrganizationCustomPermissionKey, OrganizationCustomRoleKey, @@ -42,7 +42,7 @@ export type SignedInAuthObject = { */ factorVerificationAge: [number, number] | null; getToken: ServerGetToken; - has: CheckAuthorizationWithCustomPermissions; + has: CheckAuthorizationFromSessionClaims; debug: AuthObjectDebug; }; @@ -65,7 +65,7 @@ export type SignedOutAuthObject = { */ factorVerificationAge: null; getToken: ServerGetToken; - has: CheckAuthorizationWithCustomPermissions; + has: CheckAuthorizationFromSessionClaims; debug: AuthObjectDebug; }; diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts index e8e912cf435..8079e6a8bc1 100644 --- a/packages/nextjs/src/app-router/server/auth.ts +++ b/packages/nextjs/src/app-router/server/auth.ts @@ -52,7 +52,7 @@ export const auth: AuthFn = async () => { return Object.assign(authObject, { redirectToSignIn }); }; -auth.protect = async (...args) => { +auth.protect = async (...args: any[]) => { require('server-only'); const request = await buildRequestLike(); @@ -66,6 +66,5 @@ auth.protect = async (...args) => { redirect, }); - // @ts-expect-error TS flattens all possible combinations of the for AuthProtect signatures in a union. return protect(...args); }; diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index 9d06d9e10a5..99bbacd6bee 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -113,6 +113,17 @@ describe('ClerkMiddleware type tests', () => { clerkMiddlewareMock(); }); + it('prevents usage of system permissions with auth.has()', () => { + clerkMiddlewareMock(async (auth, _event, _request) => { + // @ts-expect-error - system permissions are not allowed + (await auth()).has({ permission: 'org:sys_foo' }); + // @ts-expect-error - system permissions are not allowed + await auth.protect(has => has({ permission: 'org:sys_foo' })); + // @ts-expect-error - system permissions are not allowed + await auth.protect({ permission: 'org:sys_foo' }); + }); + }); + describe('Multi domain', () => { const defaultProps = { publishableKey: '', secretKey: '' }; diff --git a/packages/nextjs/src/server/protect.ts b/packages/nextjs/src/server/protect.ts index 7e17e37817c..3c683b42216 100644 --- a/packages/nextjs/src/server/protect.ts +++ b/packages/nextjs/src/server/protect.ts @@ -2,8 +2,11 @@ import type { AuthObject } from '@clerk/backend'; import type { RedirectFun, SignedInAuthObject } from '@clerk/backend/internal'; import { constants } from '@clerk/backend/internal'; import type { + CheckAuthorizationFromSessionClaims, + CheckAuthorizationParamsFromSessionClaims, CheckAuthorizationParamsWithCustomPermissions, CheckAuthorizationWithCustomPermissions, + OrganizationCustomPermissionKey, } from '@clerk/types'; import { constants as nextConstants } from '../constants'; @@ -15,10 +18,13 @@ type AuthProtectOptions = { unauthorizedUrl?: string; unauthenticatedUrl?: strin * Throws a Nextjs notFound error if user is not authenticated or authorized. */ export interface AuthProtect { - (params?: CheckAuthorizationParamsWithCustomPermissions, options?: AuthProtectOptions): Promise; +

( + params?: CheckAuthorizationParamsFromSessionClaims

, + options?: AuthProtectOptions, + ): Promise; ( - params?: (has: CheckAuthorizationWithCustomPermissions) => boolean, + params?: (has: CheckAuthorizationFromSessionClaims) => boolean, options?: AuthProtectOptions, ): Promise; diff --git a/packages/react/src/hooks/__tests__/useAuth.test.tsx b/packages/react/src/hooks/__tests__/useAuth.test.tsx index c4272cff3b4..8576e90f9d7 100644 --- a/packages/react/src/hooks/__tests__/useAuth.test.tsx +++ b/packages/react/src/hooks/__tests__/useAuth.test.tsx @@ -188,4 +188,12 @@ describe('useDerivedAuth', () => { expect(current.has?.({ permission: 'test' })).toBe('mocked-result'); expect(mockHas).toHaveBeenCalledWith({ permission: 'test' }); }); + + it('allows to pass system permissions', () => { + const { + result: { current }, + } = renderHook(() => useDerivedAuth({ sessionId: null, userId: null })); + + current.has?.({ permission: 'org:sys_foo' }); + }); }); diff --git a/packages/types/src/jwtv2.ts b/packages/types/src/jwtv2.ts index 73ef4232f4d..3bd27594f9f 100644 --- a/packages/types/src/jwtv2.ts +++ b/packages/types/src/jwtv2.ts @@ -97,7 +97,7 @@ export interface JwtPayload extends CustomJwtSessionClaims { org_role?: OrganizationCustomRoleKey; /** - * Active organization role + * Active organization permissions */ org_permissions?: OrganizationCustomPermissionKey[]; diff --git a/packages/types/src/organizationMembership.ts b/packages/types/src/organizationMembership.ts index 8a010d19911..93cf0be204c 100644 --- a/packages/types/src/organizationMembership.ts +++ b/packages/types/src/organizationMembership.ts @@ -68,13 +68,14 @@ export type OrganizationCustomRoleKey = ClerkAuthorization extends Placeholder : Base['role'] : Base['role']; +export type OrganizationSystemPermissionPrefix = 'org:sys_'; export type OrganizationSystemPermissionKey = - | 'org:sys_domains:manage' - | 'org:sys_profile:manage' - | 'org:sys_profile:delete' - | 'org:sys_memberships:read' - | 'org:sys_memberships:manage' - | 'org:sys_domains:read'; + | `${OrganizationSystemPermissionPrefix}domains:manage` + | `${OrganizationSystemPermissionPrefix}profile:manage` + | `${OrganizationSystemPermissionPrefix}profile:delete` + | `${OrganizationSystemPermissionPrefix}memberships:read` + | `${OrganizationSystemPermissionPrefix}memberships:manage` + | `${OrganizationSystemPermissionPrefix}domains:read`; /** * OrganizationPermissionKey is a combination of system and custom permissions. diff --git a/packages/types/src/session.ts b/packages/types/src/session.ts index 028ceacee31..65bc988ab56 100644 --- a/packages/types/src/session.ts +++ b/packages/types/src/session.ts @@ -15,6 +15,7 @@ import type { OrganizationCustomPermissionKey, OrganizationCustomRoleKey, OrganizationPermissionKey, + OrganizationSystemPermissionPrefix, } from './organizationMembership'; import type { ClerkResource } from './resource'; import type { @@ -25,6 +26,10 @@ import type { import type { TokenResource } from './token'; import type { UserResource } from './user'; +type DisallowSystemPermissions

= P extends `${OrganizationSystemPermissionPrefix}${string}` + ? 'System permissions are not included in session claims and cannot be used on the server-side' + : P; + export type CheckAuthorizationFn = (isAuthorizedParams: Params) => boolean; export type CheckAuthorizationWithCustomPermissions = @@ -63,6 +68,27 @@ type CheckAuthorizationParams = WithReverification< } >; +/** + * Type guard for server-side authorization checks using session claims. + * System permissions are not allowed since they are not included + * in session claims and cannot be verified on the server side. + */ +export type CheckAuthorizationFromSessionClaims =

( + isAuthorizedParams: CheckAuthorizationParamsFromSessionClaims

, +) => boolean; + +export type CheckAuthorizationParamsFromSessionClaims

= WithReverification< + | { + role: OrganizationCustomRoleKey; + permission?: never; + } + | { + role?: never; + permission: DisallowSystemPermissions

; + } + | { role?: never; permission?: never } +>; + export interface SessionResource extends ClerkResource { id: string; status: SessionStatus;