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/packages/backend/src/jwt/verifyJwt.ts b/packages/backend/src/jwt/verifyJwt.ts index 4eabd424581..c65e220cf67 100644 --- a/packages/backend/src/jwt/verifyJwt.ts +++ b/packages/backend/src/jwt/verifyJwt.ts @@ -76,6 +76,7 @@ export function decodeJwt(token: string): JwtReturnType { 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/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index f674c3b9b44..df0837eb19d 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -97,7 +97,15 @@ export function signedInAuthObject( orgPermissions, factorVerificationAge, getToken, - has: createCheckAuthorization({ orgId, orgRole, orgPermissions, userId, factorVerificationAge }), + has: createCheckAuthorization({ + orgId, + orgRole, + orgPermissions, + userId, + factorVerificationAge, + features: (sessionClaims.fea as string) || '', + plans: (sessionClaims.pla as string) || '', + }), debug: createDebug({ ...authenticateContext, sessionToken }), }; } 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/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); }; 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 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/__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, }, diff --git a/packages/react/src/hooks/useAuth.ts b/packages/react/src/hooks/useAuth.ts index 9282175f2f3..840eb4f80b0 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, @@ -149,7 +150,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]) => { @@ -162,6 +164,8 @@ export function useDerivedAuth( orgRole, orgPermissions, factorVerificationAge, + features: ((sessionClaims as JwtPayload | undefined)?.fea as string) || '', + plans: ((sessionClaims as JwtPayload | undefined)?.pla as string) || '', })(params); }, [has, userId, orgId, orgRole, orgPermissions, factorVerificationAge], diff --git a/packages/shared/src/__tests__/jwtPayloadParser.test.ts b/packages/shared/src/__tests__/jwtPayloadParser.test.ts index b18aec5e675..8290050df65 100644 --- a/packages/shared/src/__tests__/jwtPayloadParser.test.ts +++ b/packages/shared/src/__tests__/jwtPayloadParser.test.ts @@ -1,7 +1,5 @@ -import { - __experimental_JWTPayloadToAuthObjectProperties as JWTPayloadToAuthObjectProperties, - parseFeatures, -} from '../jwtPayloadParser'; +import { splitByScope } from '../authorization'; +import { __experimental_JWTPayloadToAuthObjectProperties as JWTPayloadToAuthObjectProperties } from '../jwtPayloadParser'; const baseClaims = { exp: 1234567890, @@ -187,37 +185,37 @@ describe('JWTPayloadToAuthObjectProperties', () => { }); }); -describe('parseFeatures ', () => { +describe('splitByScope ', () => { test('returns empty array when no features are present', () => { - const { orgFeatures } = parseFeatures(''); - expect(orgFeatures).toEqual([]); + const { org } = splitByScope(''); + expect(org).toEqual([]); }); test('only org features included', () => { - const { orgFeatures, userFeatures } = parseFeatures('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 } = parseFeatures('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 } = parseFeatures('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 } = parseFeatures('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']); }); }); diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index 7d630ac0568..716ba70a012 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -21,11 +21,18 @@ type AuthorizationOptions = { orgRole: string | null | undefined; orgPermissions: string[] | null | undefined; factorVerificationAge: [number, number] | null; + features: string | null | undefined; + plans: string | null | undefined; }; 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 = ( @@ -73,6 +80,7 @@ const checkOrgAuthorization: CheckOrgAuthorization = (params, options) => { if (!params.role && !params.permission) { return null; } + if (!orgId || !orgRole || !orgPermissions) { return null; } @@ -80,12 +88,51 @@ 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 && 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()) : []; + + // 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; @@ -155,14 +202,15 @@ const createCheckAuthorization = (options: AuthorizationOptions): CheckAuthoriza return false; } + const billingAuthorization = checkBillingAuthorization(params, options); 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 ([billingAuthorization || orgAuthorization, reverificationAuthorization].some(a => a === null)) { + return [billingAuthorization || orgAuthorization, reverificationAuthorization].some(a => a === true); } - return [orgAuthorization, reverificationAuthorization].every(a => a === true); + return [billingAuthorization || orgAuthorization, reverificationAuthorization].every(a => a === true); }; }; @@ -291,4 +339,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, }); 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 2aa60e190e1..7642a9c64d3 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'; /** * @inline @@ -57,12 +58,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; @@ -71,15 +88,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 } >; /** @@ -95,12 +125,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 } >; /**