From 0e684d39336658c48f9dbaa544bada4837f94482 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:08:01 -0600 Subject: [PATCH 1/5] fix(shared): Throw error on unknown scopes --- .changeset/moody-sites-lead.md | 5 ++ .../src/__tests__/authorization.spec.ts | 39 ++++++++++++ packages/shared/src/authorization.ts | 60 ++++++++++++++----- 3 files changed, 88 insertions(+), 16 deletions(-) create mode 100644 .changeset/moody-sites-lead.md create mode 100644 packages/shared/src/__tests__/authorization.spec.ts 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/shared/src/__tests__/authorization.spec.ts b/packages/shared/src/__tests__/authorization.spec.ts new file mode 100644 index 00000000000..2d5e2564ace --- /dev/null +++ b/packages/shared/src/__tests__/authorization.spec.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } 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']); + }); +}); diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index 0834ecb86e2..69719f14ab2 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,29 @@ 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[] = []; + + 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(':'); + 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); + } + } - // 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]), - }; + return { org, user }; }; const validateReverificationConfig = (config: ReverificationConfig | undefined | null) => { From 2cf478631d65dad648fca0e3c45f20bee9c949dc Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Wed, 4 Feb 2026 12:01:01 -0600 Subject: [PATCH 2/5] fix(shared): Throw error on claim element missing colon --- packages/shared/src/__tests__/authorization.spec.ts | 4 ++++ packages/shared/src/authorization.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/packages/shared/src/__tests__/authorization.spec.ts b/packages/shared/src/__tests__/authorization.spec.ts index 2d5e2564ace..c2aa885d88c 100644 --- a/packages/shared/src/__tests__/authorization.spec.ts +++ b/packages/shared/src/__tests__/authorization.spec.ts @@ -36,4 +36,8 @@ describe('splitByScope', () => { 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 69719f14ab2..db669b3f4a1 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -148,6 +148,9 @@ const splitByScope = (fea: string | null | undefined) => { 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); From 708d7db8bfe3f07d803b9343febeaa58def5cba1 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Fri, 6 Feb 2026 10:51:10 -0600 Subject: [PATCH 3/5] chore(clerk-js): bundlewatch --- packages/clerk-js/bundlewatch.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" }, From a371353efba888888379fce91d5d8fd6d15a7594 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:12:10 -0600 Subject: [PATCH 4/5] chore(shared): lint --- packages/shared/src/authorization.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index db669b3f4a1..db2af474f93 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -142,7 +142,9 @@ const splitByScope = (fea: string | null | undefined) => { const org: string[] = []; const user: string[] = []; - if (!fea) return { org, user }; + if (!fea) { + return { org, user }; + } const parts = fea.split(','); for (let i = 0; i < parts.length; i++) { From a9b2223ed44128bba3e7cf55943d38018307ce0f Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:30:19 -0600 Subject: [PATCH 5/5] lint(shared): sort imports --- packages/shared/src/__tests__/authorization.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/__tests__/authorization.spec.ts b/packages/shared/src/__tests__/authorization.spec.ts index c2aa885d88c..7037305ed43 100644 --- a/packages/shared/src/__tests__/authorization.spec.ts +++ b/packages/shared/src/__tests__/authorization.spec.ts @@ -1,4 +1,5 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; + import { createCheckAuthorization, splitByScope } from '../authorization'; describe('createCheckAuthorization', () => {