Skip to content

Commit 8902e21

Browse files
authored
fix(shared): Throw error on unknown scopes (#7754)
1 parent 556fddc commit 8902e21

File tree

4 files changed

+99
-17
lines changed

4 files changed

+99
-17
lines changed

.changeset/moody-sites-lead.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/shared': major
3+
---
4+
5+
Adjust features parsing to throw errors on unknown scopes.

packages/clerk-js/bundlewatch.config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
{ "path": "./dist/clerk.browser.js", "maxSize": "66KB" },
55
{ "path": "./dist/clerk.chips.browser.js", "maxSize": "66KB" },
66
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "106KB" },
7-
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "305KB" },
7+
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "307KB" },
88
{ "path": "./dist/clerk.native.js", "maxSize": "65KB" },
99
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
1010
{ "path": "./dist/coinbase*.js", "maxSize": "36KB" },
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { createCheckAuthorization, splitByScope } from '../authorization';
4+
5+
describe('createCheckAuthorization', () => {
6+
it('correctly parses features', () => {
7+
const checkAuthorization = createCheckAuthorization({
8+
userId: 'user_123',
9+
orgId: 'org_123',
10+
orgRole: 'admin',
11+
orgPermissions: ['org:read'],
12+
features: 'o:reservations,u:dashboard',
13+
plans: 'free_user,plus_user',
14+
factorVerificationAge: [1000, 2000],
15+
});
16+
expect(checkAuthorization({ feature: 'o:reservations' })).toBe(true);
17+
expect(checkAuthorization({ feature: 'org:reservations' })).toBe(true);
18+
expect(checkAuthorization({ feature: 'organization:reservations' })).toBe(true);
19+
expect(checkAuthorization({ feature: 'reservations' })).toBe(true);
20+
expect(checkAuthorization({ feature: 'u:dashboard' })).toBe(true);
21+
expect(checkAuthorization({ feature: 'user:dashboard' })).toBe(true);
22+
expect(checkAuthorization({ feature: 'dashboard' })).toBe(true);
23+
24+
expect(() => checkAuthorization({ feature: 'lol:dashboard' })).toThrow('Invalid scope: lol');
25+
});
26+
});
27+
28+
describe('splitByScope', () => {
29+
it('correctly splits features by scope', () => {
30+
const { org, user } = splitByScope('o:reservations,u:dashboard');
31+
expect(org).toEqual(['reservations']);
32+
expect(user).toEqual(['dashboard']);
33+
});
34+
35+
it('correctly splits features by scope with multiple scopes', () => {
36+
const { org, user } = splitByScope('o:reservations,u:dashboard,ou:support-chat,uo:billing');
37+
expect(org).toEqual(['reservations', 'support-chat', 'billing']);
38+
expect(user).toEqual(['dashboard', 'support-chat', 'billing']);
39+
});
40+
41+
it('throws an error if the claim element is missing a colon', () => {
42+
expect(() => splitByScope('reservations,dashboard')).toThrow('Invalid claim element (missing colon): reservations');
43+
});
44+
});

packages/shared/src/authorization.ts

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ const ALLOWED_LEVELS = new Set<SessionVerificationLevel>(['first_factor', 'secon
6565

6666
const ALLOWED_TYPES = new Set<SessionVerificationTypes>(['strict_mfa', 'strict', 'moderate', 'lax']);
6767

68+
const ORG_SCOPES = new Set(['o', 'org', 'organization']);
69+
const USER_SCOPES = new Set(['u', 'user']);
70+
6871
// Helper functions
6972
const isValidMaxAge = (maxAge: any) => typeof maxAge === 'number' && maxAge > 0;
7073
const isValidLevel = (level: any) => ALLOWED_LEVELS.has(level);
@@ -100,17 +103,26 @@ const checkOrgAuthorization: CheckOrgAuthorization = (params, options) => {
100103

101104
const checkForFeatureOrPlan = (claim: string, featureOrPlan: string) => {
102105
const { org: orgFeatures, user: userFeatures } = splitByScope(claim);
103-
const [scope, _id] = featureOrPlan.split(':');
104-
const id = _id || scope;
105-
106-
if (scope === 'org') {
107-
return orgFeatures.includes(id);
108-
} else if (scope === 'user') {
109-
return userFeatures.includes(id);
110-
} else {
111-
// Since org scoped features will not exist if there is not an active org, merging is safe.
112-
return [...orgFeatures, ...userFeatures].includes(id);
106+
const [rawScope, rawId] = featureOrPlan.split(':');
107+
const hasExplicitScope = rawId !== undefined;
108+
const scope = rawScope;
109+
const id = rawId || rawScope;
110+
111+
if (hasExplicitScope && !ORG_SCOPES.has(scope) && !USER_SCOPES.has(scope)) {
112+
throw new Error(`Invalid scope: ${scope}`);
113113
}
114+
115+
if (hasExplicitScope) {
116+
if (ORG_SCOPES.has(scope)) {
117+
return orgFeatures.includes(id);
118+
}
119+
if (USER_SCOPES.has(scope)) {
120+
return userFeatures.includes(id);
121+
}
122+
}
123+
124+
// Since org scoped features will not exist if there is not an active org, merging is safe.
125+
return [...orgFeatures, ...userFeatures].includes(id);
114126
};
115127

116128
const checkBillingAuthorization: CheckBillingAuthorization = (params, options) => {
@@ -127,13 +139,34 @@ const checkBillingAuthorization: CheckBillingAuthorization = (params, options) =
127139
};
128140

129141
const splitByScope = (fea: string | null | undefined) => {
130-
const features = fea ? fea.split(',').map(f => f.trim()) : [];
142+
const org: string[] = [];
143+
const user: string[] = [];
131144

132-
// TODO: make this more efficient
133-
return {
134-
org: features.filter(f => f.split(':')[0].includes('o')).map(f => f.split(':')[1]),
135-
user: features.filter(f => f.split(':')[0].includes('u')).map(f => f.split(':')[1]),
136-
};
145+
if (!fea) {
146+
return { org, user };
147+
}
148+
149+
const parts = fea.split(',');
150+
for (let i = 0; i < parts.length; i++) {
151+
const part = parts[i].trim();
152+
const colonIndex = part.indexOf(':');
153+
if (colonIndex === -1) {
154+
throw new Error(`Invalid claim element (missing colon): ${part}`);
155+
}
156+
const scope = part.slice(0, colonIndex);
157+
const value = part.slice(colonIndex + 1);
158+
159+
if (scope === 'o') {
160+
org.push(value);
161+
} else if (scope === 'u') {
162+
user.push(value);
163+
} else if (scope === 'ou' || scope === 'uo') {
164+
org.push(value);
165+
user.push(value);
166+
}
167+
}
168+
169+
return { org, user };
137170
};
138171

139172
const validateReverificationConfig = (config: ReverificationConfig | undefined | null) => {

0 commit comments

Comments
 (0)