diff --git a/.changeset/moody-sites-lead.md b/.changeset/moody-sites-lead.md new file mode 100644 index 00000000000..92c03b82f50 --- /dev/null +++ b/.changeset/moody-sites-lead.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': major +--- + +Adjust features parsing to throw errors on unknown scopes. diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 3bd20ec6fee..272648511b7 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -4,7 +4,7 @@ { "path": "./dist/clerk.browser.js", "maxSize": "66KB" }, { "path": "./dist/clerk.chips.browser.js", "maxSize": "66KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "106KB" }, - { "path": "./dist/clerk.no-rhc.js", "maxSize": "305KB" }, + { "path": "./dist/clerk.no-rhc.js", "maxSize": "307KB" }, { "path": "./dist/clerk.native.js", "maxSize": "65KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, { "path": "./dist/coinbase*.js", "maxSize": "36KB" }, diff --git a/packages/shared/src/__tests__/authorization.spec.ts b/packages/shared/src/__tests__/authorization.spec.ts new file mode 100644 index 00000000000..7037305ed43 --- /dev/null +++ b/packages/shared/src/__tests__/authorization.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { createCheckAuthorization, splitByScope } from '../authorization'; + +describe('createCheckAuthorization', () => { + it('correctly parses features', () => { + const checkAuthorization = createCheckAuthorization({ + userId: 'user_123', + orgId: 'org_123', + orgRole: 'admin', + orgPermissions: ['org:read'], + features: 'o:reservations,u:dashboard', + plans: 'free_user,plus_user', + factorVerificationAge: [1000, 2000], + }); + expect(checkAuthorization({ feature: 'o:reservations' })).toBe(true); + expect(checkAuthorization({ feature: 'org:reservations' })).toBe(true); + expect(checkAuthorization({ feature: 'organization:reservations' })).toBe(true); + expect(checkAuthorization({ feature: 'reservations' })).toBe(true); + expect(checkAuthorization({ feature: 'u:dashboard' })).toBe(true); + expect(checkAuthorization({ feature: 'user:dashboard' })).toBe(true); + expect(checkAuthorization({ feature: 'dashboard' })).toBe(true); + + expect(() => checkAuthorization({ feature: 'lol:dashboard' })).toThrow('Invalid scope: lol'); + }); +}); + +describe('splitByScope', () => { + it('correctly splits features by scope', () => { + const { org, user } = splitByScope('o:reservations,u:dashboard'); + expect(org).toEqual(['reservations']); + expect(user).toEqual(['dashboard']); + }); + + it('correctly splits features by scope with multiple scopes', () => { + const { org, user } = splitByScope('o:reservations,u:dashboard,ou:support-chat,uo:billing'); + expect(org).toEqual(['reservations', 'support-chat', 'billing']); + expect(user).toEqual(['dashboard', 'support-chat', 'billing']); + }); + + it('throws an error if the claim element is missing a colon', () => { + expect(() => splitByScope('reservations,dashboard')).toThrow('Invalid claim element (missing colon): reservations'); + }); +}); diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index 0834ecb86e2..db2af474f93 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -65,6 +65,9 @@ const ALLOWED_LEVELS = new Set(['first_factor', 'secon const ALLOWED_TYPES = new Set(['strict_mfa', 'strict', 'moderate', 'lax']); +const ORG_SCOPES = new Set(['o', 'org', 'organization']); +const USER_SCOPES = new Set(['u', 'user']); + // Helper functions const isValidMaxAge = (maxAge: any) => typeof maxAge === 'number' && maxAge > 0; const isValidLevel = (level: any) => ALLOWED_LEVELS.has(level); @@ -100,17 +103,26 @@ const checkOrgAuthorization: CheckOrgAuthorization = (params, options) => { 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 [rawScope, rawId] = featureOrPlan.split(':'); + const hasExplicitScope = rawId !== undefined; + const scope = rawScope; + const id = rawId || rawScope; + + if (hasExplicitScope && !ORG_SCOPES.has(scope) && !USER_SCOPES.has(scope)) { + throw new Error(`Invalid scope: ${scope}`); } + + if (hasExplicitScope) { + if (ORG_SCOPES.has(scope)) { + return orgFeatures.includes(id); + } + if (USER_SCOPES.has(scope)) { + return userFeatures.includes(id); + } + } + + // 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) => { @@ -127,13 +139,34 @@ const checkBillingAuthorization: CheckBillingAuthorization = (params, options) = }; const splitByScope = (fea: string | null | undefined) => { - const features = fea ? fea.split(',').map(f => f.trim()) : []; + const org: string[] = []; + const user: string[] = []; - // 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]), - }; + if (!fea) { + return { org, user }; + } + + const parts = fea.split(','); + for (let i = 0; i < parts.length; i++) { + const part = parts[i].trim(); + const colonIndex = part.indexOf(':'); + if (colonIndex === -1) { + throw new Error(`Invalid claim element (missing colon): ${part}`); + } + const scope = part.slice(0, colonIndex); + const value = part.slice(colonIndex + 1); + + if (scope === 'o') { + org.push(value); + } else if (scope === 'u') { + user.push(value); + } else if (scope === 'ou' || scope === 'uo') { + org.push(value); + user.push(value); + } + } + + return { org, user }; }; const validateReverificationConfig = (config: ReverificationConfig | undefined | null) => {