Skip to content

Commit b7fb564

Browse files
authored
fix(shared): decode JWT v2 permission masks exactly (#9381)
1 parent a088c94 commit b7fb564

3 files changed

Lines changed: 159 additions & 18 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/shared': patch
3+
---
4+
5+
Ensure organization permission checks remain accurate when JWT v2 permission masks exceed JavaScript's safe integer range.

packages/shared/src/__tests__/jwtPayloadParser.spec.ts

Lines changed: 116 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, test } from 'vitest';
22

3-
import { splitByScope } from '../authorization';
3+
import { createCheckAuthorization, splitByScope } from '../authorization';
44
import { __experimental_JWTPayloadToAuthObjectProperties as JWTPayloadToAuthObjectProperties } from '../jwtPayloadParser';
55

66
const baseClaims = {
@@ -14,6 +14,33 @@ const baseClaims = {
1414
__raw: '',
1515
};
1616

17+
const permissionNames = Array.from({ length: 54 }, (_, index) => `permission_${index}`);
18+
19+
const authFromFeaturePermissionMask = (fpm: string) => {
20+
const authObject = JWTPayloadToAuthObjectProperties({
21+
...baseClaims,
22+
v: 2,
23+
fea: 'o:feature',
24+
o: {
25+
id: 'org_id',
26+
rol: 'admin',
27+
per: permissionNames.join(','),
28+
fpm,
29+
},
30+
});
31+
const has = createCheckAuthorization({
32+
userId: authObject.userId,
33+
orgId: authObject.orgId,
34+
orgRole: authObject.orgRole,
35+
orgPermissions: authObject.orgPermissions,
36+
factorVerificationAge: authObject.factorVerificationAge,
37+
features: null,
38+
plans: null,
39+
});
40+
41+
return { authObject, has };
42+
};
43+
1744
describe('JWTPayloadToAuthObjectProperties', () => {
1845
test('auth object with JWT v2 does not produces anything org related if there is no org active', () => {
1946
const { sessionClaims: v2Claims, ...signedInAuthObjectV2 } = JWTPayloadToAuthObjectProperties({
@@ -75,7 +102,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
75102
);
76103
});
77104

78-
test('if a feature is not mapped to any permissions it is added as is to the orgPermissions array', () => {
105+
test('features without permissions use zero masks to preserve alignment', () => {
79106
const { sessionClaims: v2Claims, ...signedInAuthObject } = JWTPayloadToAuthObjectProperties({
80107
...baseClaims,
81108
v: 2,
@@ -85,7 +112,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
85112
rol: 'admin',
86113
slg: 'org_slug',
87114
per: 'read,manage',
88-
fpm: '1,3',
115+
fpm: '1,3,0',
89116
},
90117
});
91118

@@ -153,7 +180,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
153180
rol: 'admin',
154181
slg: 'org_slug',
155182
per: 'read,manage',
156-
fpm: '3',
183+
fpm: '3,0',
157184
},
158185
});
159186

@@ -227,7 +254,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
227254
rol: 'admin',
228255
slg: 'org_slug',
229256
per: 'read,manage',
230-
fpm: '1,2,3',
257+
fpm: '1,2,3,0',
231258
},
232259
});
233260

@@ -246,7 +273,7 @@ describe('JWTPayloadToAuthObjectProperties', () => {
246273
rol: 'admin',
247274
slg: 'org_slug',
248275
per: 'read,create,update,delete,revoke',
249-
fpm: '7,21',
276+
fpm: '7,21,0',
250277
},
251278
});
252279

@@ -261,6 +288,89 @@ describe('JWTPayloadToAuthObjectProperties', () => {
261288
].sort(),
262289
);
263290
});
291+
292+
test('preserves permissions above the safe integer boundary', () => {
293+
const { authObject, has } = authFromFeaturePermissionMask('9007199254740993');
294+
295+
expect(authObject.orgPermissions).toEqual(['org:feature:permission_0', 'org:feature:permission_53']);
296+
expect(has({ permission: 'org:feature:permission_0' })).toBe(true);
297+
expect(has({ permission: 'org:feature:permission_53' })).toBe(true);
298+
});
299+
300+
test('does not introduce permissions when decoding a mask above the safe integer boundary', () => {
301+
const { authObject, has } = authFromFeaturePermissionMask('9007199254740995');
302+
303+
expect(authObject.orgPermissions).toEqual([
304+
'org:feature:permission_0',
305+
'org:feature:permission_1',
306+
'org:feature:permission_53',
307+
]);
308+
expect(has({ permission: 'org:feature:permission_0' })).toBe(true);
309+
expect(has({ permission: 'org:feature:permission_1' })).toBe(true);
310+
expect(has({ permission: 'org:feature:permission_2' })).toBe(false);
311+
expect(has({ permission: 'org:feature:permission_53' })).toBe(true);
312+
});
313+
314+
test('discards mask bits outside the declared permission list', () => {
315+
const { authObject, has } = authFromFeaturePermissionMask('18014398509481984');
316+
317+
expect(authObject.orgPermissions).toEqual([]);
318+
expect(has({ permission: 'org:feature:undefined' })).toBe(false);
319+
});
320+
321+
test('keeps permission masks aligned when a targeted feature follows a feature without permissions', () => {
322+
const authObject = JWTPayloadToAuthObjectProperties({
323+
...baseClaims,
324+
v: 2,
325+
fea: 'o:repositories,o:impersonation,o:billing',
326+
o: {
327+
id: 'org_id',
328+
rol: 'admin',
329+
per: 'manage,read,update',
330+
fpm: '6,0,3',
331+
},
332+
});
333+
const has = createCheckAuthorization({
334+
userId: authObject.userId,
335+
orgId: authObject.orgId,
336+
orgRole: authObject.orgRole,
337+
orgPermissions: authObject.orgPermissions,
338+
factorVerificationAge: authObject.factorVerificationAge,
339+
features: null,
340+
plans: null,
341+
});
342+
343+
expect(authObject.orgPermissions).toEqual([
344+
'org:repositories:read',
345+
'org:repositories:update',
346+
'org:billing:manage',
347+
'org:billing:read',
348+
]);
349+
expect(has({ permission: 'org:impersonation:manage' })).toBe(false);
350+
expect(has({ permission: 'org:billing:manage' })).toBe(true);
351+
});
352+
353+
test.each([
354+
['1', [0]],
355+
['3', [0, 1]],
356+
['7', [0, 1, 2]],
357+
['21', [0, 2, 4]],
358+
])('preserves permissions for the safe mask %s', (fpm, expectedPermissionIndexes) => {
359+
const { authObject, has } = authFromFeaturePermissionMask(fpm);
360+
const expectedPermissions = expectedPermissionIndexes.map(index => `org:feature:permission_${index}`);
361+
362+
expect(authObject.orgPermissions).toEqual(expectedPermissions);
363+
for (const permission of expectedPermissions) {
364+
expect(has({ permission })).toBe(true);
365+
}
366+
});
367+
368+
test.each(['1invalid', '-1', '1.5'])('fails closed for the malformed mask %s', fpm => {
369+
const { authObject, has } = authFromFeaturePermissionMask(fpm);
370+
371+
expect(authObject.orgPermissions).toEqual([]);
372+
expect(has({ permission: 'org:feature:permission_0' })).toBe(false);
373+
});
264374
});
265375

266376
describe('splitByScope ', () => {

packages/shared/src/jwtPayloadParser.ts

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,52 @@ import type {
66
SharedSignedInAuthObjectProperties,
77
} from './types';
88

9+
const decimalToBinaryBits = (decimal: string, minimumLength: number): number[] | undefined => {
10+
if (!/^\d+$/.test(decimal)) {
11+
return undefined;
12+
}
13+
14+
let remaining = decimal.replace(/^0+/, '') || '0';
15+
const bits: number[] = [];
16+
17+
while (remaining !== '0') {
18+
let quotient = '';
19+
let remainder = 0;
20+
21+
for (let i = 0; i < remaining.length; i++) {
22+
const value = remainder * 10 + remaining.charCodeAt(i) - 48;
23+
const quotientDigit = Math.floor(value / 2);
24+
25+
if (quotient || quotientDigit !== 0) {
26+
quotient += quotientDigit;
27+
}
28+
remainder = value % 2;
29+
}
30+
31+
bits.push(remainder);
32+
remaining = quotient || '0';
33+
}
34+
35+
if (bits.length === 0) {
36+
bits.push(0);
37+
}
38+
while (bits.length < minimumLength) {
39+
bits.push(0);
40+
}
41+
42+
return bits;
43+
};
44+
945
export const parsePermissions = ({ per, fpm }: { per?: string; fpm?: string }) => {
1046
if (!per || !fpm) {
1147
return { permissions: [], featurePermissionMap: [] };
1248
}
1349

1450
const permissions = per.split(',').map(p => p.trim());
1551

16-
// TODO: make this more efficient
1752
const featurePermissionMap = fpm
1853
.split(',')
19-
.map(permission => Number.parseInt(permission.trim(), 10))
20-
.map((permission: number) =>
21-
permission
22-
.toString(2)
23-
.padStart(permissions.length, '0')
24-
.split('')
25-
.map(bit => Number.parseInt(bit, 10))
26-
.reverse(),
27-
)
28-
.filter(Boolean);
54+
.map(permission => decimalToBinaryBits(permission.trim(), permissions.length) ?? []);
2955

3056
return { permissions, featurePermissionMap };
3157
};
@@ -62,7 +88,7 @@ function buildOrgPermissions({
6288
continue;
6389
}
6490

65-
for (let permIndex = 0; permIndex < permissionBits.length; permIndex++) {
91+
for (let permIndex = 0; permIndex < permissionBits.length && permIndex < permissions.length; permIndex++) {
6692
if (permissionBits[permIndex] === 1) {
6793
orgPermissions.push(`org:${feature}:${permissions[permIndex]}`);
6894
}

0 commit comments

Comments
 (0)