From dec1fb4f45285466a1dc27a31516e199499376d0 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 10 Apr 2025 17:31:51 +0300 Subject: [PATCH 01/17] wip --- packages/backend/src/jwt/verifyJwt.ts | 32 ++++++++- packages/backend/src/tokens/authObjects.ts | 10 ++- packages/shared/src/authorization.ts | 81 +++++++++++++++++++++- packages/types/src/jwtv2.ts | 5 ++ packages/types/src/session.ts | 50 ++++++++++++- 5 files changed, 171 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/jwt/verifyJwt.ts b/packages/backend/src/jwt/verifyJwt.ts index 4eabd424581..1e0859b3b4a 100644 --- a/packages/backend/src/jwt/verifyJwt.ts +++ b/packages/backend/src/jwt/verifyJwt.ts @@ -75,12 +75,42 @@ export function decodeJwt(token: string): JwtReturnType { +// return (params): boolean => { +// if (!options.userId) { +// return false; +// } + +// const orgAuthorization = checkOrgAuthorization(params, options); +// const reverificationAuthorization = checkReverificationAuthorization(params, options); + +// if ([orgAuthorization, reverificationAuthorization].some(a => a === null)) { +// return [orgAuthorization, reverificationAuthorization].some(a => a === true); +// } + +// return [orgAuthorization, reverificationAuthorization].every(a => a === true); +// }; +// }; + +const parseScope = (fea: string | null | undefined) => { + const features = fea ? fea.split(',').map(f => f.trim()) : []; + + const featuresByScope = features + .map(feature => { + const [scope, id] = feature.split(':'); + return { scope, id }; + }) + .reduce( + (acc, curr) => { + acc[curr.scope] = [...(acc[curr.scope] || []), curr.id]; + return acc; + }, + {} as Record, + ); + + // TODO: make this more efficient + return { + org: [...(featuresByScope['o'] || []), ...(featuresByScope['uo'] || [])], + user: [...(featuresByScope['u'] || []), ...(featuresByScope['uo'] || [])], + }; +}; + const createCheckAuthorization = (options: AuthorizationOptions): CheckAuthorizationWithCustomPermissions => { return (params): boolean => { if (!options.userId) { return false; } + let commerceAuthorization: boolean | null = null; + const { reverification, ...restParams } = params; + + if (restParams.feature) { + const [scope, id] = restParams.feature.split(':'); + const { org: orgFeatures, user: userFeatures } = parseScope(options.features); + if (scope === 'org') { + commerceAuthorization = orgFeatures.includes(id); + } else if (scope === 'user') { + commerceAuthorization = userFeatures.includes(id); + } else { + if (options.orgId) { + commerceAuthorization = orgFeatures.includes(id); + } else { + commerceAuthorization = userFeatures.includes(id); + } + } + } else if (restParams.plan) { + const { org: orgPlans, user: userPlans } = parseScope(options.features); + const [scope, id] = restParams.plan.split(':'); + if (scope === 'org') { + commerceAuthorization = orgPlans.includes(id); + } else if (scope === 'user') { + commerceAuthorization = userPlans.includes(id); + } else { + if (options.orgId) { + commerceAuthorization = orgPlans.includes(id); + } else { + commerceAuthorization = userPlans.includes(id); + } + } + } + const orgAuthorization = checkOrgAuthorization(params, options); const reverificationAuthorization = checkReverificationAuthorization(params, options); - if ([orgAuthorization, reverificationAuthorization].some(a => a === null)) { - return [orgAuthorization, reverificationAuthorization].some(a => a === true); + if ([commerceAuthorization, orgAuthorization, reverificationAuthorization].some(a => a === null)) { + return [commerceAuthorization, orgAuthorization, reverificationAuthorization].some(a => a === true); } - return [orgAuthorization, reverificationAuthorization].every(a => a === true); + return [commerceAuthorization, orgAuthorization, reverificationAuthorization].every(a => a === true); }; }; diff --git a/packages/types/src/jwtv2.ts b/packages/types/src/jwtv2.ts index c08f4316b3c..0d58c9e9ad1 100644 --- a/packages/types/src/jwtv2.ts +++ b/packages/types/src/jwtv2.ts @@ -139,6 +139,11 @@ export type VersionedJwtPayload = */ fea?: string; + /** + * Plans for session. + */ + pla?: string; + /** * @experimental - This structure is subject to change. * diff --git a/packages/types/src/session.ts b/packages/types/src/session.ts index 75c6d1dfde4..0c008373ebc 100644 --- a/packages/types/src/session.ts +++ b/packages/types/src/session.ts @@ -26,6 +26,7 @@ import type { import type { SessionJSONSnapshot } from './snapshots'; import type { TokenResource } from './token'; import type { UserResource } from './user'; +import type { Autocomplete } from './utils'; export type PendingSessionOptions = { /** @@ -52,12 +53,28 @@ export type CheckAuthorizationParamsWithCustomPermissions = WithReverification< | { role: OrganizationCustomRoleKey; permission?: never; + feature?: never; + plan?: never; } | { role?: never; permission: OrganizationCustomPermissionKey; + feature?: never; + plan?: never; } - | { role?: never; permission?: never } + | { + role?: never; + permission?: never; + feature: Autocomplete<`user:${string}` | `org:${string}`>; + plan?: never; + } + | { + role?: never; + permission?: never; + feature?: never; + plan: Autocomplete<`user:${string}` | `org:${string}`>; + } + | { role?: never; permission?: never; feature?: never; plan?: never } >; export type CheckAuthorization = CheckAuthorizationFn; @@ -66,15 +83,28 @@ type CheckAuthorizationParams = WithReverification< | { role: OrganizationCustomRoleKey; permission?: never; + feature?: never; + plan?: never; } | { role?: never; permission: OrganizationPermissionKey; + feature?: never; + plan?: never; + } + | { + role?: never; + permission?: never; + feature: Autocomplete<`user:${string}` | `org:${string}`>; + plan?: never; } | { role?: never; permission?: never; + feature?: never; + plan: Autocomplete<`user:${string}` | `org:${string}`>; } + | { role?: never; permission?: never; feature?: never; plan?: never } >; /** @@ -90,12 +120,28 @@ export type CheckAuthorizationParamsFromSessionClaims

; + feature?: never; + plan?: never; + } + | { + role?: never; + permission?: never; + feature: Autocomplete<`user:${string}` | `org:${string}`>; + plan?: never; + } + | { + role?: never; + permission?: never; + feature?: never; + plan: Autocomplete<`user:${string}` | `org:${string}`>; } - | { role?: never; permission?: never } + | { role?: never; permission?: never; feature?: never; plan?: never } >; /** From 2a3dccd14cb60cc67aa85abe81c3d13a9f9c7cc1 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 10 Apr 2025 17:42:44 +0300 Subject: [PATCH 02/17] wip 2 --- packages/react/src/hooks/useAuth.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react/src/hooks/useAuth.ts b/packages/react/src/hooks/useAuth.ts index 756d85e2145..8150ef0ff24 100644 --- a/packages/react/src/hooks/useAuth.ts +++ b/packages/react/src/hooks/useAuth.ts @@ -157,6 +157,8 @@ export function useDerivedAuth( orgRole, orgPermissions, factorVerificationAge, + features: '', + plans: '', })(params); }, [has, userId, orgId, orgRole, orgPermissions, factorVerificationAge], From ce7119e6c76e65d4e7921fb6f150e290536db085 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 10 Apr 2025 17:43:21 +0300 Subject: [PATCH 03/17] wip changeset --- .changeset/loud-glasses-notice.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/loud-glasses-notice.md diff --git a/.changeset/loud-glasses-notice.md b/.changeset/loud-glasses-notice.md new file mode 100644 index 00000000000..01b6b8e4422 --- /dev/null +++ b/.changeset/loud-glasses-notice.md @@ -0,0 +1,8 @@ +--- +'@clerk/backend': minor +'@clerk/shared': minor +'@clerk/clerk-react': minor +'@clerk/types': minor +--- + +WIP From ba332cec1326fe1411ed00beea2597d3a3bf2612 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 10 Apr 2025 17:54:57 +0300 Subject: [PATCH 04/17] wip 3 --- .../src/components/controlComponents.tsx | 30 ++++++++++++++++++- packages/react/src/hooks/useAuth.ts | 8 +++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/react/src/components/controlComponents.tsx b/packages/react/src/components/controlComponents.tsx index fdfc000af0f..1d4ff88703d 100644 --- a/packages/react/src/components/controlComponents.tsx +++ b/packages/react/src/components/controlComponents.tsx @@ -1,5 +1,6 @@ import { deprecated } from '@clerk/shared/deprecated'; import type { + Autocomplete, CheckAuthorizationWithCustomPermissions, HandleOAuthCallbackParams, OrganizationCustomPermissionKey, @@ -61,21 +62,43 @@ export type ProtectProps = React.PropsWithChildren< condition?: never; role: OrganizationCustomRoleKey; permission?: never; + feature?: never; + plan?: never; } | { condition?: never; role?: never; + feature?: never; + plan?: never; permission: OrganizationCustomPermissionKey; } | { condition: (has: CheckAuthorizationWithCustomPermissions) => boolean; role?: never; permission?: never; + feature?: never; + plan?: never; } | { condition?: never; role?: never; permission?: never; + feature: Autocomplete<`user:${string}` | `org:${string}`>; + plan?: never; + } + | { + condition?: never; + role?: never; + permission?: never; + feature?: never; + plan: Autocomplete<`user:${string}` | `org:${string}`>; + } + | { + condition?: never; + role?: never; + permission?: never; + feature?: never; + plan?: never; } ) & { fallback?: React.ReactNode; @@ -127,7 +150,12 @@ export const Protect = ({ children, fallback, treatPendingAsSignedOut, ...restAu return unauthorized; } - if (restAuthorizedParams.role || restAuthorizedParams.permission) { + if ( + restAuthorizedParams.role || + restAuthorizedParams.permission || + restAuthorizedParams.feature || + restAuthorizedParams.plan + ) { if (has(restAuthorizedParams)) { return authorized; } diff --git a/packages/react/src/hooks/useAuth.ts b/packages/react/src/hooks/useAuth.ts index 8150ef0ff24..f30ab90acc5 100644 --- a/packages/react/src/hooks/useAuth.ts +++ b/packages/react/src/hooks/useAuth.ts @@ -3,6 +3,7 @@ import { eventMethodCalled } from '@clerk/shared/telemetry'; import type { CheckAuthorizationWithCustomPermissions, GetToken, + JwtPayload, PendingSessionOptions, SignOut, UseAuthReturn, @@ -144,7 +145,8 @@ export function useDerivedAuth( authObject: any, { treatPendingAsSignedOut = true }: PendingSessionOptions = {}, ): UseAuthReturn { - const { userId, orgId, orgRole, has, signOut, getToken, orgPermissions, factorVerificationAge } = authObject ?? {}; + const { userId, orgId, orgRole, has, signOut, getToken, orgPermissions, factorVerificationAge, sessionClaims } = + authObject ?? {}; const derivedHas = useCallback( (params: Parameters[0]) => { @@ -157,8 +159,8 @@ export function useDerivedAuth( orgRole, orgPermissions, factorVerificationAge, - features: '', - plans: '', + features: ((sessionClaims as JwtPayload | undefined)?.fea as string) || '', + plans: ((sessionClaims as JwtPayload | undefined)?.pla as string) || '', })(params); }, [has, userId, orgId, orgRole, orgPermissions, factorVerificationAge], From de1d809d966cb0c74699387a7773f428c2856764 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 10 Apr 2025 18:16:04 +0300 Subject: [PATCH 05/17] hack around plans --- packages/shared/src/authorization.ts | 62 +++++++++++++++++++--------- 1 file changed, 43 insertions(+), 19 deletions(-) diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index d9029926f32..6a91466fa1b 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -168,28 +168,48 @@ const checkReverificationAuthorization: CheckReverificationAuthorization = (para // }; // }; -const parseScope = (fea: string | null | undefined) => { - const features = fea ? fea.split(',').map(f => f.trim()) : []; - - const featuresByScope = features - .map(feature => { - const [scope, id] = feature.split(':'); - return { scope, id }; - }) - .reduce( - (acc, curr) => { - acc[curr.scope] = [...(acc[curr.scope] || []), curr.id]; - return acc; - }, - {} as Record, - ); +export const parseScope = (fea: string | null | undefined) => { + const features = (fea ? fea.split(',').map(f => f.trim()) : []).map(f => { + const partsLength = f.split(':').length; + + if (partsLength === 1) { + return `uo:${f}`; + } + + return f; + }); + + console.log('features', features); // TODO: make this more efficient return { - org: [...(featuresByScope['o'] || []), ...(featuresByScope['uo'] || [])], - user: [...(featuresByScope['u'] || []), ...(featuresByScope['uo'] || [])], + org: features.filter(f => f.split(':')[0].includes('o')).map(f => f.split(':')[1]), + user: features.filter(f => f.split(':')[0].includes('u')).map(f => f.split(':')[1]), }; }; +// +// const parseScope = (fea: string | null | undefined) => { +// const features = fea ? fea.split(',').map(f => f.trim()) : []; +// +// const featuresByScope = features +// .map(feature => { +// const [scope, id] = feature.split(':'); +// return { scope, id }; +// }) +// .reduce( +// (acc, curr) => { +// acc[curr.scope] = [...(acc[curr.scope] || []), curr.id]; +// return acc; +// }, +// {} as Record, +// ); +// +// // TODO: make this more efficient +// return { +// org: [...(featuresByScope['o'] || []), ...(featuresByScope['uo'] || [])], +// user: [...(featuresByScope['u'] || []), ...(featuresByScope['uo'] || [])], +// }; +// }; const createCheckAuthorization = (options: AuthorizationOptions): CheckAuthorizationWithCustomPermissions => { return (params): boolean => { @@ -215,8 +235,10 @@ const createCheckAuthorization = (options: AuthorizationOptions): CheckAuthoriza } } } else if (restParams.plan) { - const { org: orgPlans, user: userPlans } = parseScope(options.features); - const [scope, id] = restParams.plan.split(':'); + const { org: orgPlans, user: userPlans } = parseScope(options.plans); + const [scope, _id] = restParams.plan.split(':'); + const id = _id || scope; + console.log('orgPlans', orgPlans, scope, id); if (scope === 'org') { commerceAuthorization = orgPlans.includes(id); } else if (scope === 'user') { @@ -224,8 +246,10 @@ const createCheckAuthorization = (options: AuthorizationOptions): CheckAuthoriza } else { if (options.orgId) { commerceAuthorization = orgPlans.includes(id); + console.log('orgId', options.orgId); } else { commerceAuthorization = userPlans.includes(id); + console.log('user', options.userId); } } } From a91635fa45b959efea9277f2a0b9abfdd3b43972 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 10 Apr 2025 18:25:11 +0300 Subject: [PATCH 06/17] remove mock jwt --- packages/backend/src/jwt/verifyJwt.ts | 61 ++++++++++++++------------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/packages/backend/src/jwt/verifyJwt.ts b/packages/backend/src/jwt/verifyJwt.ts index 1e0859b3b4a..2e7b74675d7 100644 --- a/packages/backend/src/jwt/verifyJwt.ts +++ b/packages/backend/src/jwt/verifyJwt.ts @@ -75,42 +75,43 @@ export function decodeJwt(token: string): JwtReturnType Date: Thu, 10 Apr 2025 18:27:54 +0300 Subject: [PATCH 07/17] fix build --- packages/clerk-js/src/core/resources/Session.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index f8c43c2180a..2abb36cc3e5 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -115,6 +115,8 @@ export class Session extends BaseResource implements SessionResource { orgId: activeMembership?.id, orgRole: activeMembership?.role, orgPermissions: activeMembership?.permissions, + features: (this.lastActiveToken?.jwt?.claims.fea as string) || '', + plans: (this.lastActiveToken?.jwt?.claims.pla as string) || '', })(params); }; From fa03fb6b5043e32094c35ad6a9f85249b22fb174 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 10 Apr 2025 18:28:56 +0300 Subject: [PATCH 08/17] remove logs --- packages/shared/src/authorization.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index 6a91466fa1b..7c59eec8b83 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -179,8 +179,6 @@ export const parseScope = (fea: string | null | undefined) => { return f; }); - console.log('features', features); - // TODO: make this more efficient return { org: features.filter(f => f.split(':')[0].includes('o')).map(f => f.split(':')[1]), @@ -238,7 +236,7 @@ const createCheckAuthorization = (options: AuthorizationOptions): CheckAuthoriza const { org: orgPlans, user: userPlans } = parseScope(options.plans); const [scope, _id] = restParams.plan.split(':'); const id = _id || scope; - console.log('orgPlans', orgPlans, scope, id); + if (scope === 'org') { commerceAuthorization = orgPlans.includes(id); } else if (scope === 'user') { @@ -246,10 +244,8 @@ const createCheckAuthorization = (options: AuthorizationOptions): CheckAuthoriza } else { if (options.orgId) { commerceAuthorization = orgPlans.includes(id); - console.log('orgId', options.orgId); } else { commerceAuthorization = userPlans.includes(id); - console.log('user', options.userId); } } } From d6f122258a38033ef394b1785c750962e84edeb2 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 11 Apr 2025 11:31:48 +0300 Subject: [PATCH 09/17] refactor --- .../src/tokens/__tests__/authObjects.test.ts | 142 ++++++++++++++++ .../src/__tests__/jwtPayloadParser.test.ts | 17 +- packages/shared/src/authorization.ts | 160 +++++++----------- packages/shared/src/jwtPayloadParser.ts | 14 +- 4 files changed, 214 insertions(+), 119 deletions(-) diff --git a/packages/backend/src/tokens/__tests__/authObjects.test.ts b/packages/backend/src/tokens/__tests__/authObjects.test.ts index bb63cb7b850..42a15099b89 100644 --- a/packages/backend/src/tokens/__tests__/authObjects.test.ts +++ b/packages/backend/src/tokens/__tests__/authObjects.test.ts @@ -31,4 +31,146 @@ describe('signedInAuthObject', () => { const token = await authObject.getToken(); expect(token).toBe('token'); }); + + describe('JWT v1', () => { + it('has() for orgs', () => { + const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext; + + const partialJwtPayload = { + ___raw: 'raw', + act: { sub: 'actor' }, + sid: 'sessionId', + org_id: 'orgId', + org_role: 'org:admin', + org_slug: 'orgSlug', + org_permissions: ['org:f1:read', 'org:f2:manage'], + sub: 'userId', + } as Partial; + + const authObject = signedInAuthObject(mockAuthenticateContext, 'token', partialJwtPayload as JwtPayload); + + expect(authObject.has({ role: 'org:admin' })).toBe(true); + expect(authObject.has({ permission: 'org:f1:read' })).toBe(true); + expect(authObject.has({ permission: 'org:f1' })).toBe(false); + expect(authObject.has({ permission: 'org:f2:manage' })).toBe(true); + expect(authObject.has({ permission: 'org:f2' })).toBe(false); + + expect(authObject.has({ feature: 'org:reservations' })).toBe(false); + expect(authObject.has({ feature: 'org:impersonation' })).toBe(false); + }); + }); + + describe('JWT v2', () => { + it('has() for orgs', () => { + const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext; + + const partialJwtPayload = { + v: 2, + ___raw: 'raw', + act: { sub: 'actor' }, + sid: 'sessionId', + fea: 'o:reservations,o:impersonation', + o: { + id: 'orgId', + rol: 'admin', + slg: 'orgSlug', + per: 'read,manage', + fpm: '3', + }, + + sub: 'userId', + } as Partial; + + const authObject = signedInAuthObject(mockAuthenticateContext, 'token', partialJwtPayload as JwtPayload); + + expect(authObject.has({ role: 'org:admin' })).toBe(true); + expect(authObject.has({ permission: 'org:reservations:read' })).toBe(true); + expect(authObject.has({ permission: 'org:reservations' })).toBe(false); + expect(authObject.has({ permission: 'org:reservations:manage' })).toBe(true); + expect(authObject.has({ permission: 'org:reservations' })).toBe(false); + expect(authObject.has({ permission: 'org:impersonation:read' })).toBe(false); + expect(authObject.has({ permission: 'org:impersonation:manage' })).toBe(false); + + expect(authObject.has({ feature: 'org:reservations' })).toBe(true); + expect(authObject.has({ feature: 'org:impersonation' })).toBe(true); + }); + + it('has() for billing with scopes', () => { + const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext; + + const partialJwtPayload = { + v: 2, + ___raw: 'raw', + act: { sub: 'actor' }, + sid: 'sessionId', + fea: 'o:reservations,u:dashboard,uo:support-chat,o:impersonation', + o: { + id: 'orgId', + rol: 'member', + slg: 'orgSlug', + per: 'read,manage', + fpm: '2,3', + }, + pla: 'u:pro,o:business', + sub: 'userId', + } as Partial; + + const authObject = signedInAuthObject(mockAuthenticateContext, 'token', partialJwtPayload as JwtPayload); + + expect(authObject.has({ permission: 'org:reservations:read' })).toBe(true); + expect(authObject.has({ permission: 'org:reservations:manage' })).toBe(false); + + expect(authObject.has({ permission: 'org:support-chat:read' })).toBe(true); + expect(authObject.has({ permission: 'org:support-chat:manage' })).toBe(true); + + expect(authObject.has({ permission: 'u:dashboard:manage' })).toBe(false); + expect(authObject.has({ permission: 'u:dashboard:read' })).toBe(false); + + expect(authObject.has({ feature: 'org:reservations' })).toBe(true); + expect(authObject.has({ feature: 'user:reservations' })).toBe(false); + expect(authObject.has({ feature: 'org:impersonation' })).toBe(true); + expect(authObject.has({ feature: 'user:impersonation' })).toBe(false); + expect(authObject.has({ feature: 'org:dashboard' })).toBe(false); + expect(authObject.has({ feature: 'user:dashboard' })).toBe(true); + expect(authObject.has({ feature: 'org:support-chat' })).toBe(true); + expect(authObject.has({ feature: 'user:support-chat' })).toBe(true); + + expect(authObject.has({ plan: 'org:business' })).toBe(true); + expect(authObject.has({ plan: 'user:business' })).toBe(false); + + expect(authObject.has({ plan: 'org:pro' })).toBe(false); + expect(authObject.has({ plan: 'user:pro' })).toBe(true); + }); + + it('has() for billing without scopes', () => { + const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext; + + const partialJwtPayload = { + v: 2, + ___raw: 'raw', + act: { sub: 'actor' }, + sid: 'sessionId', + fea: 'o:reservations,u:dashboard,uo:support-chat,o:impersonation', + o: { + id: 'orgId', + rol: 'member', + slg: 'orgSlug', + per: 'read,manage', + fpm: '2,3', + }, + pla: 'u:pro,o:business', + sub: 'userId', + } as Partial; + + const authObject = signedInAuthObject(mockAuthenticateContext, 'token', partialJwtPayload as JwtPayload); + + expect(authObject.has({ feature: 'reservations' })).toBe(true); // because the org has it. + expect(authObject.has({ feature: 'dashboard' })).toBe(true); // because the user has it. + expect(authObject.has({ feature: 'pro' })).toBe(false); // `pro` is a plan + expect(authObject.has({ feature: 'impersonation' })).toBe(true); // because the org has it. + + expect(authObject.has({ plan: 'pro' })).toBe(true); // because the user has it. + expect(authObject.has({ plan: 'business' })).toBe(true); // because the org has it. + }); + }); }); diff --git a/packages/shared/src/__tests__/jwtPayloadParser.test.ts b/packages/shared/src/__tests__/jwtPayloadParser.test.ts index b18aec5e675..e4378ef068c 100644 --- a/packages/shared/src/__tests__/jwtPayloadParser.test.ts +++ b/packages/shared/src/__tests__/jwtPayloadParser.test.ts @@ -1,7 +1,4 @@ -import { - __experimental_JWTPayloadToAuthObjectProperties as JWTPayloadToAuthObjectProperties, - parseFeatures, -} from '../jwtPayloadParser'; +import { __experimental_JWTPayloadToAuthObjectProperties as JWTPayloadToAuthObjectProperties } from '../jwtPayloadParser'; const baseClaims = { exp: 1234567890, @@ -187,35 +184,35 @@ describe('JWTPayloadToAuthObjectProperties', () => { }); }); -describe('parseFeatures ', () => { +describe('splitByScope ', () => { test('returns empty array when no features are present', () => { - const { orgFeatures } = parseFeatures(''); + const { orgFeatures } = splitByScope(''); expect(orgFeatures).toEqual([]); }); test('only org features included', () => { - const { orgFeatures, userFeatures } = parseFeatures('o:impersonation,o:payments'); + const { orgFeatures, userFeatures } = splitByScope('o:impersonation,o:payments'); expect(orgFeatures).toEqual(['impersonation', 'payments']); expect(userFeatures).toEqual([]); }); test('only user features included', () => { - const { orgFeatures, userFeatures } = parseFeatures('u:impersonation,u:payments'); + const { orgFeatures, userFeatures } = splitByScope('u:impersonation,u:payments'); expect(orgFeatures).toEqual([]); expect(userFeatures).toEqual(['impersonation', 'payments']); }); test('both org and user features included', () => { - const { orgFeatures, userFeatures } = parseFeatures('o:payments,u:impersonation'); + const { orgFeatures, userFeatures } = splitByScope('o:payments,u:impersonation'); expect(orgFeatures).toEqual(['payments']); expect(userFeatures).toEqual(['impersonation']); }); test('features have multiple scopes', () => { - const { orgFeatures, userFeatures } = parseFeatures('ou:payments,u:impersonation'); + const { orgFeatures, userFeatures } = splitByScope('ou:payments,u:impersonation'); expect(orgFeatures).toEqual(['payments']); expect(userFeatures).toEqual(['payments', 'impersonation']); diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index 7c59eec8b83..aeaff13b130 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -27,7 +27,12 @@ type AuthorizationOptions = { type CheckOrgAuthorization = ( params: { role?: OrganizationCustomRoleKey; permission?: OrganizationCustomPermissionKey }, - { orgId, orgRole, orgPermissions }: AuthorizationOptions, + options: Pick, +) => boolean | null; + +type CheckBillingAuthorization = ( + params: { feature?: string; plan?: string }, + options: Pick, ) => boolean | null; type CheckReverificationAuthorization = ( @@ -75,6 +80,7 @@ const checkOrgAuthorization: CheckOrgAuthorization = (params, options) => { if (!params.role && !params.permission) { return null; } + if (!orgId || !orgRole || !orgPermissions) { return null; } @@ -82,12 +88,62 @@ const checkOrgAuthorization: CheckOrgAuthorization = (params, options) => { if (params.permission) { return orgPermissions.includes(params.permission); } + if (params.role) { return orgRole === params.role; } return null; }; +const checkForFeatureOrPlan = (claim: string, featureOrPlan: string) => { + const { org: orgFeatures, user: userFeatures } = splitByScope(claim); + const [scope, _id] = featureOrPlan.split(':'); + const id = _id || scope; + + if (scope === 'org') { + return orgFeatures.includes(id); + } else if (scope === 'user') { + return userFeatures.includes(id); + } else { + // Since org scoped features will not exist if there is not an active org, merging is safe. + return [...orgFeatures, ...userFeatures].includes(id); + } +}; + +const checkBillingAuthorization: CheckBillingAuthorization = (params, options) => { + const { features, plans } = options; + if (!params.feature && !params.plan) { + return null; + } + + if (params.feature && features) { + return checkForFeatureOrPlan(features, params.feature); + } + + if (params.plan && plans) { + return checkForFeatureOrPlan(plans, params.plan); + } + return null; +}; + +const splitByScope = (fea: string | null | undefined) => { + const features = (fea ? fea.split(',').map(f => f.trim()) : []).map(f => { + const partsLength = f.split(':').length; + + if (partsLength === 1) { + return `uo:${f}`; + } + + return f; + }); + + // TODO: make this more efficient + return { + org: features.filter(f => f.split(':')[0].includes('o')).map(f => f.split(':')[1]), + user: features.filter(f => f.split(':')[0].includes('u')).map(f => f.split(':')[1]), + }; +}; + const validateReverificationConfig = (config: ReverificationConfig | undefined | null) => { if (!config) { return false; @@ -151,113 +207,21 @@ const checkReverificationAuthorization: CheckReverificationAuthorization = (para * The returned function authorizes if both checks pass, or if at least one passes * when the other is indeterminate. Fails if userId is missing. */ -// const createCheckAuthorization = (options: AuthorizationOptions): CheckAuthorizationWithCustomPermissions => { -// return (params): boolean => { -// if (!options.userId) { -// return false; -// } - -// const orgAuthorization = checkOrgAuthorization(params, options); -// const reverificationAuthorization = checkReverificationAuthorization(params, options); - -// if ([orgAuthorization, reverificationAuthorization].some(a => a === null)) { -// return [orgAuthorization, reverificationAuthorization].some(a => a === true); -// } - -// return [orgAuthorization, reverificationAuthorization].every(a => a === true); -// }; -// }; - -export const parseScope = (fea: string | null | undefined) => { - const features = (fea ? fea.split(',').map(f => f.trim()) : []).map(f => { - const partsLength = f.split(':').length; - - if (partsLength === 1) { - return `uo:${f}`; - } - - return f; - }); - - // TODO: make this more efficient - return { - org: features.filter(f => f.split(':')[0].includes('o')).map(f => f.split(':')[1]), - user: features.filter(f => f.split(':')[0].includes('u')).map(f => f.split(':')[1]), - }; -}; -// -// const parseScope = (fea: string | null | undefined) => { -// const features = fea ? fea.split(',').map(f => f.trim()) : []; -// -// const featuresByScope = features -// .map(feature => { -// const [scope, id] = feature.split(':'); -// return { scope, id }; -// }) -// .reduce( -// (acc, curr) => { -// acc[curr.scope] = [...(acc[curr.scope] || []), curr.id]; -// return acc; -// }, -// {} as Record, -// ); -// -// // TODO: make this more efficient -// return { -// org: [...(featuresByScope['o'] || []), ...(featuresByScope['uo'] || [])], -// user: [...(featuresByScope['u'] || []), ...(featuresByScope['uo'] || [])], -// }; -// }; - const createCheckAuthorization = (options: AuthorizationOptions): CheckAuthorizationWithCustomPermissions => { return (params): boolean => { if (!options.userId) { return false; } - let commerceAuthorization: boolean | null = null; - const { reverification, ...restParams } = params; - - if (restParams.feature) { - const [scope, id] = restParams.feature.split(':'); - const { org: orgFeatures, user: userFeatures } = parseScope(options.features); - if (scope === 'org') { - commerceAuthorization = orgFeatures.includes(id); - } else if (scope === 'user') { - commerceAuthorization = userFeatures.includes(id); - } else { - if (options.orgId) { - commerceAuthorization = orgFeatures.includes(id); - } else { - commerceAuthorization = userFeatures.includes(id); - } - } - } else if (restParams.plan) { - const { org: orgPlans, user: userPlans } = parseScope(options.plans); - const [scope, _id] = restParams.plan.split(':'); - const id = _id || scope; - - if (scope === 'org') { - commerceAuthorization = orgPlans.includes(id); - } else if (scope === 'user') { - commerceAuthorization = userPlans.includes(id); - } else { - if (options.orgId) { - commerceAuthorization = orgPlans.includes(id); - } else { - commerceAuthorization = userPlans.includes(id); - } - } - } - + const billingAuthorization = checkBillingAuthorization(params, options); const orgAuthorization = checkOrgAuthorization(params, options); const reverificationAuthorization = checkReverificationAuthorization(params, options); - if ([commerceAuthorization, orgAuthorization, reverificationAuthorization].some(a => a === null)) { - return [commerceAuthorization, orgAuthorization, reverificationAuthorization].some(a => a === true); + if ([billingAuthorization, orgAuthorization, reverificationAuthorization].some(a => a === null)) { + return [billingAuthorization, orgAuthorization, reverificationAuthorization].some(a => a === true); } - return [commerceAuthorization, orgAuthorization, reverificationAuthorization].every(a => a === true); + return [billingAuthorization, orgAuthorization, reverificationAuthorization].every(a => a === true); }; }; @@ -386,4 +350,4 @@ const resolveAuthState = ({ } }; -export { createCheckAuthorization, validateReverificationConfig, resolveAuthState }; +export { createCheckAuthorization, validateReverificationConfig, resolveAuthState, splitByScope }; diff --git a/packages/shared/src/jwtPayloadParser.ts b/packages/shared/src/jwtPayloadParser.ts index 4d1cc10c2fc..129512c5fb2 100644 --- a/packages/shared/src/jwtPayloadParser.ts +++ b/packages/shared/src/jwtPayloadParser.ts @@ -5,15 +5,7 @@ import type { SharedSignedInAuthObjectProperties, } from '@clerk/types'; -export const parseFeatures = (fea: string | undefined) => { - const features = fea ? fea.split(',').map(f => f.trim()) : []; - - // TODO: make this more efficient - return { - orgFeatures: features.filter(f => f.split(':')[0].includes('o')).map(f => f.split(':')[1]), - userFeatures: features.filter(f => f.split(':')[0].includes('u')).map(f => f.split(':')[1]), - }; -}; +import { splitByScope } from './authorization'; export const parsePermissions = ({ per, fpm }: { per?: string; fpm?: string }) => { if (!per || !fpm) { @@ -101,13 +93,13 @@ const __experimental_JWTPayloadToAuthObjectProperties = (claims: JwtPayload): Sh if (claims.o?.rol) { orgRole = `org:${claims.o?.rol}`; } - const { orgFeatures } = parseFeatures(claims.fea); + const { org } = splitByScope(claims.fea); const { permissions, featurePermissionMap } = parsePermissions({ per: claims.o?.per, fpm: claims.o?.fpm, }); orgPermissions = buildOrgPermissions({ - features: orgFeatures, + features: org, featurePermissionMap: featurePermissionMap, permissions: permissions, }); From 4408a03146cbb2654b44e270847f0381fb6449c1 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 11 Apr 2025 11:32:10 +0300 Subject: [PATCH 10/17] remove mock jwt --- packages/backend/src/jwt/verifyJwt.ts | 32 +-------------------------- 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/packages/backend/src/jwt/verifyJwt.ts b/packages/backend/src/jwt/verifyJwt.ts index 2e7b74675d7..c65e220cf67 100644 --- a/packages/backend/src/jwt/verifyJwt.ts +++ b/packages/backend/src/jwt/verifyJwt.ts @@ -75,43 +75,13 @@ export function decodeJwt(token: string): JwtReturnType Date: Fri, 11 Apr 2025 11:52:56 +0300 Subject: [PATCH 11/17] type tests --- .../src/hooks/__tests__/useAuth.type.test.ts | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/react/src/hooks/__tests__/useAuth.type.test.ts b/packages/react/src/hooks/__tests__/useAuth.type.test.ts index 47ee7b0337f..7f645a3adef 100644 --- a/packages/react/src/hooks/__tests__/useAuth.type.test.ts +++ b/packages/react/src/hooks/__tests__/useAuth.type.test.ts @@ -27,10 +27,42 @@ describe('useAuth type tests', () => { expectTypeOf({ role: 'org:admin', permission: 'some-perm' }).not.toMatchTypeOf(); }); + it('has({feature}) is allowed', () => { + expectTypeOf({ + feature: 'org:feature', + }).toMatchTypeOf(); + }); + + it('has({plan}) is allowed', () => { + expectTypeOf({ + plan: 'org:pro', + }).toMatchTypeOf(); + }); + + it('has({feature: string, plan: string}) is NOT allowed', () => { + expectTypeOf({ plan: 'org:pro', feature: 'org:feature' }).not.toMatchTypeOf(); + }); + + it('has({feature: string, permission: string}) is NOT allowed', () => { + expectTypeOf({ feature: 'org:pro', permission: 'org:feature' }).not.toMatchTypeOf(); + }); + + it('has({plan: string, role: string}) is NOT allowed', () => { + expectTypeOf({ plan: 'org:pro', role: 'org:feature' }).not.toMatchTypeOf(); + }); + + it('has({plan: string, reverification}) is allowed', () => { + expectTypeOf({ plan: 'org:pro', reverification: 'lax' } as const).toMatchTypeOf(); + }); + + it('has({feature: string, reverification}) is allowed', () => { + expectTypeOf({ feature: 'org:feature', reverification: 'lax' } as const).toMatchTypeOf(); + }); + it('has with role and re-verification is allowed', () => { expectTypeOf({ role: 'org:admin', - __experimental_reverification: { + reverification: { level: 'first_factor', afterMinutes: 10, }, From 15f03ae559bbe1c6972a983aee5d5f918a4c510f Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 11 Apr 2025 11:58:49 +0300 Subject: [PATCH 12/17] remove code for supporting unsuffixed plans --- packages/shared/src/authorization.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index aeaff13b130..570f49f6f75 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -127,15 +127,7 @@ const checkBillingAuthorization: CheckBillingAuthorization = (params, options) = }; const splitByScope = (fea: string | null | undefined) => { - const features = (fea ? fea.split(',').map(f => f.trim()) : []).map(f => { - const partsLength = f.split(':').length; - - if (partsLength === 1) { - return `uo:${f}`; - } - - return f; - }); + const features = fea ? fea.split(',').map(f => f.trim()) : []; // TODO: make this more efficient return { From fe9ddbbbc7479e48ca7d6d5b1e7f1a5d623ef46d Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 11 Apr 2025 12:05:22 +0300 Subject: [PATCH 13/17] bump bundlewatch.config.json --- packages/clerk-js/bundlewatch.config.json | 2 +- .../src/__tests__/jwtPayloadParser.test.ts | 29 ++++++++++--------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 14ea235d97e..38da23426e4 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,7 +1,7 @@ { "files": [ { "path": "./dist/clerk.js", "maxSize": "590kB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "73.5KB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "73.64KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "55KB" }, { "path": "./dist/ui-common*.js", "maxSize": "99KB" }, { "path": "./dist/vendors*.js", "maxSize": "36KB" }, diff --git a/packages/shared/src/__tests__/jwtPayloadParser.test.ts b/packages/shared/src/__tests__/jwtPayloadParser.test.ts index e4378ef068c..8290050df65 100644 --- a/packages/shared/src/__tests__/jwtPayloadParser.test.ts +++ b/packages/shared/src/__tests__/jwtPayloadParser.test.ts @@ -1,3 +1,4 @@ +import { splitByScope } from '../authorization'; import { __experimental_JWTPayloadToAuthObjectProperties as JWTPayloadToAuthObjectProperties } from '../jwtPayloadParser'; const baseClaims = { @@ -186,35 +187,35 @@ describe('JWTPayloadToAuthObjectProperties', () => { describe('splitByScope ', () => { test('returns empty array when no features are present', () => { - const { orgFeatures } = splitByScope(''); - expect(orgFeatures).toEqual([]); + const { org } = splitByScope(''); + expect(org).toEqual([]); }); test('only org features included', () => { - const { orgFeatures, userFeatures } = splitByScope('o:impersonation,o:payments'); - expect(orgFeatures).toEqual(['impersonation', 'payments']); + const { org, user } = splitByScope('o:impersonation,o:payments'); + expect(org).toEqual(['impersonation', 'payments']); - expect(userFeatures).toEqual([]); + expect(user).toEqual([]); }); test('only user features included', () => { - const { orgFeatures, userFeatures } = splitByScope('u:impersonation,u:payments'); - expect(orgFeatures).toEqual([]); + const { org, user } = splitByScope('u:impersonation,u:payments'); + expect(org).toEqual([]); - expect(userFeatures).toEqual(['impersonation', 'payments']); + expect(user).toEqual(['impersonation', 'payments']); }); test('both org and user features included', () => { - const { orgFeatures, userFeatures } = splitByScope('o:payments,u:impersonation'); - expect(orgFeatures).toEqual(['payments']); + const { org, user } = splitByScope('o:payments,u:impersonation'); + expect(org).toEqual(['payments']); - expect(userFeatures).toEqual(['impersonation']); + expect(user).toEqual(['impersonation']); }); test('features have multiple scopes', () => { - const { orgFeatures, userFeatures } = splitByScope('ou:payments,u:impersonation'); - expect(orgFeatures).toEqual(['payments']); + const { org, user } = splitByScope('ou:payments,u:impersonation'); + expect(org).toEqual(['payments']); - expect(userFeatures).toEqual(['payments', 'impersonation']); + expect(user).toEqual(['payments', 'impersonation']); }); }); From ac7f631b051e97ea72da2ee5e7bfc669a8223546 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 11 Apr 2025 12:29:52 +0300 Subject: [PATCH 14/17] fix issue with reverification --- packages/shared/src/authorization.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index 570f49f6f75..7678dc8fda1 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -209,11 +209,11 @@ const createCheckAuthorization = (options: AuthorizationOptions): CheckAuthoriza const orgAuthorization = checkOrgAuthorization(params, options); const reverificationAuthorization = checkReverificationAuthorization(params, options); - if ([billingAuthorization, orgAuthorization, reverificationAuthorization].some(a => a === null)) { - return [billingAuthorization, orgAuthorization, reverificationAuthorization].some(a => a === true); + if ([billingAuthorization || orgAuthorization, reverificationAuthorization].some(a => a === null)) { + return [billingAuthorization || orgAuthorization, reverificationAuthorization].some(a => a === true); } - return [billingAuthorization, orgAuthorization, reverificationAuthorization].every(a => a === true); + return [billingAuthorization || orgAuthorization, reverificationAuthorization].every(a => a === true); }; }; From ee6ff4c9e194235c08a88959c3d31d2a9e346a49 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 11 Apr 2025 15:29:19 +0300 Subject: [PATCH 15/17] changesets --- .changeset/angry-nights-smash.md | 5 +++ .changeset/cold-bears-go.md | 5 +++ .changeset/every-feet-brush.md | 5 +++ .changeset/flat-cougars-mate.md | 5 +++ .changeset/four-doors-attack.md | 33 +++++++++++++++++ .changeset/four-streets-join.md | 17 +++++++++ .changeset/hip-sides-kick.md | 61 +++++++++++++++++++++++++++++++ .changeset/loud-glasses-notice.md | 8 ---- 8 files changed, 131 insertions(+), 8 deletions(-) create mode 100644 .changeset/angry-nights-smash.md create mode 100644 .changeset/cold-bears-go.md create mode 100644 .changeset/every-feet-brush.md create mode 100644 .changeset/flat-cougars-mate.md create mode 100644 .changeset/four-doors-attack.md create mode 100644 .changeset/four-streets-join.md create mode 100644 .changeset/hip-sides-kick.md delete mode 100644 .changeset/loud-glasses-notice.md diff --git a/.changeset/angry-nights-smash.md b/.changeset/angry-nights-smash.md new file mode 100644 index 00000000000..b6b733a349d --- /dev/null +++ b/.changeset/angry-nights-smash.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +Add support for feature or plan based authorization. diff --git a/.changeset/cold-bears-go.md b/.changeset/cold-bears-go.md new file mode 100644 index 00000000000..041185f3ac0 --- /dev/null +++ b/.changeset/cold-bears-go.md @@ -0,0 +1,5 @@ +--- +'@clerk/types': minor +--- + +Add `pla` claim to `VersionedJwtPayload`. diff --git a/.changeset/every-feet-brush.md b/.changeset/every-feet-brush.md new file mode 100644 index 00000000000..5bbab9c944f --- /dev/null +++ b/.changeset/every-feet-brush.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': minor +--- + +Replace `parseFeatures` with `splitByScope`. diff --git a/.changeset/flat-cougars-mate.md b/.changeset/flat-cougars-mate.md new file mode 100644 index 00000000000..8d3a912f1d8 --- /dev/null +++ b/.changeset/flat-cougars-mate.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': minor +--- + +Update `createCheckAuthorization` to support authorization based on features and plans. diff --git a/.changeset/four-doors-attack.md b/.changeset/four-doors-attack.md new file mode 100644 index 00000000000..efd9f8dcf88 --- /dev/null +++ b/.changeset/four-doors-attack.md @@ -0,0 +1,33 @@ +--- +'@clerk/clerk-react': minor +--- + +Add support for feature or plan based authorization + +## `useAuth()` +### Plan +- `useAuth().has({ plan: "my-plan" })` + +### Feature +- `useAuth().has({ feature: "my-feature" })` + +### Scoped per user or per org +- `useAuth().has({ feature: "org:my-feature" })` +- `useAuth().has({ feature: "user:my-feature" })` +- `useAuth().has({ plan: "user:my-plan" })` +- `useAuth().has({ plan: "org:my-plan" })` + +## `` + +### Plan +- `` + +### Feature +- `` + +### Scoped per user or per org +- `` +- `` +- `` +- `` + diff --git a/.changeset/four-streets-join.md b/.changeset/four-streets-join.md new file mode 100644 index 00000000000..c716b403314 --- /dev/null +++ b/.changeset/four-streets-join.md @@ -0,0 +1,17 @@ +--- +'@clerk/clerk-js': minor +--- + +Add support for feature or plan based authorization + +### Plan +- `Clerk.session.checkAuthorization({ plan: "my-plan" })` + +### Feature +- `Clerk.session.checkAuthorization({ feature: "my-feature" })` + +### Scoped per user or per org +- `Clerk.session.checkAuthorization({ feature: "org:my-feature" })` +- `Clerk.session.checkAuthorization({ feature: "user:my-feature" })` +- `Clerk.session.checkAuthorization({ plan: "user:my-plan" })` +- `Clerk.session.checkAuthorization({ plan: "org:my-plan" })` diff --git a/.changeset/hip-sides-kick.md b/.changeset/hip-sides-kick.md new file mode 100644 index 00000000000..397609c9a84 --- /dev/null +++ b/.changeset/hip-sides-kick.md @@ -0,0 +1,61 @@ +--- +'@clerk/nextjs': minor +--- + +Add support for feature or plan based authorization + + +## `await auth()` +### Plan +- `(await auth()).has({ plan: "my-plan" })` + +### Feature +- `(await auth()).has({ feature: "my-feature" })` + +### Scoped per user or per org +- `(await auth()).has({ feature: "org:my-feature" })` +- `(await auth()).has({ feature: "user:my-feature" })` +- `(await auth()).has({ plan: "user:my-plan" })` +- `(await auth()).has({ plan: "org:my-plan" })` + +## `auth.protect()` +### Plan +- `auth.protect({ plan: "my-plan" })` + +### Feature +- `auth.protect({ feature: "my-feature" })` + +### Scoped per user or per org +- `auth.protect({ feature: "org:my-feature" })` +- `auth.protect({ feature: "user:my-feature" })` +- `auth.protect({ plan: "user:my-plan" })` +- `auth.protect({ plan: "org:my-plan" })` + + +## `` + +### Plan +- `` + +### Feature +- `` + +### Scoped per user or per org +- `` +- `` +- `` +- `` + + +## `useAuth()` +### Plan +- `useAuth().has({ plan: "my-plan" })` + +### Feature +- `useAuth().has({ feature: "my-feature" })` + +### Scoped per user or per org +- `useAuth().has({ feature: "org:my-feature" })` +- `useAuth().has({ feature: "user:my-feature" })` +- `useAuth().has({ plan: "user:my-plan" })` +- `useAuth().has({ plan: "org:my-plan" })` diff --git a/.changeset/loud-glasses-notice.md b/.changeset/loud-glasses-notice.md deleted file mode 100644 index 01b6b8e4422..00000000000 --- a/.changeset/loud-glasses-notice.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@clerk/backend': minor -'@clerk/shared': minor -'@clerk/clerk-react': minor -'@clerk/types': minor ---- - -WIP From 031d0a5cbedfe08be5d852c4fc482bf25372c548 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 11 Apr 2025 16:28:09 +0300 Subject: [PATCH 16/17] fix protect rsc --- .../nextjs/src/app-router/server/controlComponents.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/nextjs/src/app-router/server/controlComponents.tsx b/packages/nextjs/src/app-router/server/controlComponents.tsx index 12ff7812480..48f76645f51 100644 --- a/packages/nextjs/src/app-router/server/controlComponents.tsx +++ b/packages/nextjs/src/app-router/server/controlComponents.tsx @@ -49,7 +49,12 @@ export async function Protect(props: ProtectProps): Promise Date: Sat, 12 Apr 2025 10:24:56 +0300 Subject: [PATCH 17/17] remove unnecessary code --- packages/shared/src/authorization.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index 7678dc8fda1..716ba70a012 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -112,9 +112,6 @@ const checkForFeatureOrPlan = (claim: string, featureOrPlan: string) => { const checkBillingAuthorization: CheckBillingAuthorization = (params, options) => { const { features, plans } = options; - if (!params.feature && !params.plan) { - return null; - } if (params.feature && features) { return checkForFeatureOrPlan(features, params.feature);