Skip to content

Commit 3ad3bc8

Browse files
authored
feat(types,shared): JWT v2 - Support new organization claims structure (#5549)
1 parent a2ea611 commit 3ad3bc8

File tree

11 files changed

+422
-101
lines changed

11 files changed

+422
-101
lines changed

.changeset/cool-socks-invite.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/shared': minor
3+
'@clerk/types': minor
4+
---
5+
6+
Adding the new `o` claim that contains all organization related info for JWT v2 schema

.changeset/cyan-hairs-share.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
'@clerk/backend': patch
4+
---
5+
6+
Uses the helper function `__experimental_JWTPayloadToAuthObjectProperties` from `@clerk/shared` to handle the new JWT v2 schema.

packages/backend/src/tokens/authObjects.ts

+4-53
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import { createCheckAuthorization } from '@clerk/shared/authorization';
2+
import { __experimental_JWTPayloadToAuthObjectProperties } from '@clerk/shared/jwtPayloadParser';
23
import type {
3-
ActClaim,
44
CheckAuthorizationFromSessionClaims,
55
JwtPayload,
6-
OrganizationCustomPermissionKey,
7-
OrganizationCustomRoleKey,
86
ServerGetToken,
97
ServerGetTokenOptions,
10-
SessionStatusClaim,
8+
SharedSignedInAuthObjectProperties,
119
} from '@clerk/types';
1210

1311
import type { CreateBackendApiOptions } from '../api';
@@ -27,28 +25,7 @@ export type SignedInAuthObjectOptions = CreateBackendApiOptions & {
2725
/**
2826
* @internal
2927
*/
30-
type SignedInAuthObjectProperties = {
31-
sessionClaims: JwtPayload;
32-
sessionId: string;
33-
sessionStatus: SessionStatusClaim | null;
34-
actor: ActClaim | undefined;
35-
userId: string;
36-
orgId: string | undefined;
37-
orgRole: OrganizationCustomRoleKey | undefined;
38-
orgSlug: string | undefined;
39-
orgPermissions: OrganizationCustomPermissionKey[] | undefined;
40-
/**
41-
* Factor Verification Age
42-
* Each item represents the minutes that have passed since the last time a first or second factor were verified.
43-
* [fistFactorAge, secondFactorAge]
44-
*/
45-
factorVerificationAge: [firstFactorAge: number, secondFactorAge: number] | null;
46-
};
47-
48-
/**
49-
* @internal
50-
*/
51-
export type SignedInAuthObject = SignedInAuthObjectProperties & {
28+
export type SignedInAuthObject = SharedSignedInAuthObjectProperties & {
5229
getToken: ServerGetToken;
5330
has: CheckAuthorizationFromSessionClaims;
5431
debug: AuthObjectDebug;
@@ -92,31 +69,6 @@ const createDebug = (data: AuthObjectDebugData | undefined) => {
9269
};
9370
};
9471

95-
const generateSignedInAuthObjectProperties = (claims: JwtPayload): SignedInAuthObjectProperties => {
96-
// fva can be undefined for instances that have not opt-in
97-
const factorVerificationAge = claims.fva ?? null;
98-
99-
// sts can be undefined for instances that have not opt-in
100-
const sessionStatus = claims.sts ?? null;
101-
102-
// TODO(jwt-v2): replace this when the new claim for org permissions is added, this will not break
103-
// anything since the JWT v2 is not yet available
104-
const orgPermissions = claims.org_permissions;
105-
106-
return {
107-
sessionClaims: claims,
108-
sessionId: claims.sid,
109-
sessionStatus,
110-
actor: claims.act,
111-
userId: claims.sub,
112-
orgId: claims.org_id,
113-
orgRole: claims.org_role,
114-
orgSlug: claims.org_slug,
115-
orgPermissions,
116-
factorVerificationAge,
117-
};
118-
};
119-
12072
/**
12173
* @internal
12274
*/
@@ -126,14 +78,13 @@ export function signedInAuthObject(
12678
sessionClaims: JwtPayload,
12779
): SignedInAuthObject {
12880
const { actor, sessionId, sessionStatus, userId, orgId, orgRole, orgSlug, orgPermissions, factorVerificationAge } =
129-
generateSignedInAuthObjectProperties(sessionClaims);
81+
__experimental_JWTPayloadToAuthObjectProperties(sessionClaims);
13082
const apiClient = createBackendApiClient(authenticateContext);
13183
const getToken = createGetToken({
13284
sessionId,
13385
sessionToken,
13486
fetcher: async (...args) => (await apiClient.sessions.getToken(...args)).jwt,
13587
});
136-
13788
return {
13889
actor,
13990
sessionClaims,

packages/clerk-js/bundlewatch.config.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"files": [
33
{ "path": "./dist/clerk.js", "maxSize": "590kB" },
4-
{ "path": "./dist/clerk.browser.js", "maxSize": "72.7KB" },
4+
{ "path": "./dist/clerk.browser.js", "maxSize": "73.21KB" },
55
{ "path": "./dist/clerk.headless*.js", "maxSize": "55KB" },
66
{ "path": "./dist/ui-common*.js", "maxSize": "98.2KB" },
77
{ "path": "./dist/vendors*.js", "maxSize": "36KB" },

packages/clerk-js/src/core/jwt-client.ts

+17-16
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { __experimental_JWTPayloadToAuthObjectProperties } from '@clerk/shared/jwtPayloadParser';
12
import type {
23
ClientJSON,
34
OrganizationMembershipJSON,
@@ -43,47 +44,47 @@ export function createClientFromJwt(jwt: string | undefined | null): Client {
4344
} as unknown as ClientJSON);
4445
}
4546

46-
const { sid, sub, org_id, org_role, org_permissions, org_slug, fva } = token.jwt.claims;
47-
48-
// TODO(jwt-v2): when JWT version 2 is available, we should use the new claims instead of the old ones
47+
const { sessionId, userId, orgId, orgRole, orgPermissions, orgSlug, factorVerificationAge } =
48+
__experimental_JWTPayloadToAuthObjectProperties(token.jwt.claims);
4949

50+
// TODO(jwt-v2): when JWT version 2 is available, we should revise org permissions
5051
const defaultClient = {
5152
object: 'client',
52-
last_active_session_id: sid,
53+
last_active_session_id: sessionId,
5354
id: 'client_init',
5455
sessions: [
5556
{
5657
object: 'session',
57-
id: sid,
58+
id: sessionId,
5859
status: 'active',
59-
last_active_organization_id: org_id || null,
60+
last_active_organization_id: orgId || null,
6061
// @ts-expect-error - ts is not happy about `id:undefined`, but this is allowed and expected
6162
last_active_token: {
6263
id: undefined,
6364
object: 'token',
6465
jwt,
6566
} as TokenJSON,
66-
factor_verification_age: fva || null,
67+
factor_verification_age: factorVerificationAge || null,
6768
public_user_data: {
68-
user_id: sub,
69+
user_id: userId,
6970
} as PublicUserDataJSON,
7071
user: {
7172
object: 'user',
72-
id: sub,
73+
id: userId,
7374
organization_memberships:
74-
org_id && org_slug && org_role
75+
orgId && orgSlug && orgRole
7576
? [
7677
{
7778
object: 'organization_membership',
78-
id: org_id,
79-
role: org_role,
80-
permissions: org_permissions || [],
79+
id: orgId,
80+
role: orgRole,
81+
permissions: orgPermissions || [],
8182
organization: {
8283
object: 'organization',
83-
id: org_id,
84+
id: orgId,
8485
// Use slug as name for the organization, since name is not available in the token.
85-
name: org_slug,
86-
slug: org_slug,
86+
name: orgSlug,
87+
slug: orgSlug,
8788
members_count: 1,
8889
max_allowed_memberships: 1,
8990
},

packages/shared/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,8 @@
117117
"web3",
118118
"getEnvVariable",
119119
"pathMatcher",
120-
"organization"
120+
"organization",
121+
"jwtPayloadParser"
121122
],
122123
"scripts": {
123124
"build": "tsup",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { __experimental_JWTPayloadToAuthObjectProperties as JWTPayloadToAuthObjectProperties } from '../jwtPayloadParser';
2+
3+
const baseClaims = {
4+
exp: 1234567890,
5+
iat: 1234567890,
6+
iss: 'https://api.clerk.com',
7+
sub: 'sub',
8+
sid: 'sid',
9+
azp: 'azp',
10+
nbf: 1234567890,
11+
__raw: '',
12+
};
13+
14+
describe('JWTPayloadToAuthObjectProperties', () => {
15+
test('auth object with JWT v2 does not produces anything org related if there is no org active', () => {
16+
const { sessionClaims: v2Claims, ...signedInAuthObjectV2 } = JWTPayloadToAuthObjectProperties({
17+
...baseClaims,
18+
v: 2,
19+
fea: 'u:impersonation,u:memberships',
20+
});
21+
22+
const { sessionClaims: v1Claims, ...signedInAuthObjectV1 } = JWTPayloadToAuthObjectProperties({
23+
...baseClaims,
24+
});
25+
expect(signedInAuthObjectV1).toEqual(signedInAuthObjectV2);
26+
expect(signedInAuthObjectV1.orgId).toBeUndefined();
27+
expect(signedInAuthObjectV1.orgPermissions).toBeUndefined();
28+
expect(signedInAuthObjectV1.orgRole).toBeUndefined();
29+
expect(signedInAuthObjectV1.orgSlug).toBeUndefined();
30+
});
31+
32+
test('produced auth object is the same for v1 and v2', () => {
33+
const { sessionClaims: v2Claims, ...signedInAuthObjectV2 } = JWTPayloadToAuthObjectProperties({
34+
...baseClaims,
35+
v: 2,
36+
fea: 'o:impersonation',
37+
o: {
38+
id: 'org_xxxxxxx',
39+
rol: 'admin',
40+
slg: '/test',
41+
per: 'read,manage',
42+
fpm: '3',
43+
},
44+
});
45+
46+
const { sessionClaims: v1Claims, ...signedInAuthObjectV1 } = JWTPayloadToAuthObjectProperties({
47+
...baseClaims,
48+
org_id: 'org_xxxxxxx',
49+
org_role: 'org:admin',
50+
org_slug: '/test',
51+
org_permissions: ['org:impersonation:read', 'org:impersonation:manage'],
52+
});
53+
expect(signedInAuthObjectV1).toEqual(signedInAuthObjectV2);
54+
});
55+
56+
test('produced auth object is the same for v1 and v2', () => {
57+
const { sessionClaims: v2Claims, ...signedInAuthObjectV2 } = JWTPayloadToAuthObjectProperties({
58+
...baseClaims,
59+
v: 2,
60+
fea: 'o:impersonation',
61+
o: {
62+
id: 'org_xxxxxxx',
63+
rol: 'admin',
64+
slg: '/test',
65+
per: 'read,manage',
66+
fpm: '3',
67+
},
68+
});
69+
70+
const { sessionClaims: v1Claims, ...signedInAuthObjectV1 } = JWTPayloadToAuthObjectProperties({
71+
...baseClaims,
72+
org_id: 'org_xxxxxxx',
73+
org_role: 'org:admin',
74+
org_slug: '/test',
75+
org_permissions: ['org:impersonation:read', 'org:impersonation:manage'],
76+
});
77+
expect(signedInAuthObjectV1).toEqual(signedInAuthObjectV2);
78+
});
79+
80+
test('org permissions are generated correctly when fea, per, and fpm are present', () => {
81+
const { sessionClaims: v2Claims, ...signedInAuthObject } = JWTPayloadToAuthObjectProperties({
82+
...baseClaims,
83+
v: 2,
84+
fea: 'o:impersonation,o:memberships',
85+
o: {
86+
id: 'org_xxxxxxx',
87+
rol: 'admin',
88+
slg: '/test',
89+
per: 'read,manage',
90+
fpm: '2,3',
91+
},
92+
});
93+
94+
expect(signedInAuthObject.orgPermissions?.sort()).toEqual(
95+
['org:impersonation:read', 'org:memberships:read', 'org:memberships:manage'].sort(),
96+
);
97+
});
98+
99+
test('if a feature is not mapped to any permissions it is added as is to the orgPermissions array', () => {
100+
const { sessionClaims: v2Claims, ...signedInAuthObject } = JWTPayloadToAuthObjectProperties({
101+
...baseClaims,
102+
v: 2,
103+
fea: 'o:impersonation,o:memberships,o:feature3',
104+
o: {
105+
id: 'org_id',
106+
rol: 'admin',
107+
slg: 'org_slug',
108+
per: 'read,manage',
109+
fpm: '2,3',
110+
},
111+
});
112+
113+
expect(signedInAuthObject.orgPermissions?.sort()).toEqual(
114+
['org:impersonation:read', 'org:memberships:read', 'org:memberships:manage'].sort(),
115+
);
116+
});
117+
118+
test('includes both org and user scoped features', () => {
119+
const { sessionClaims: v2Claims, ...signedInAuthObject } = JWTPayloadToAuthObjectProperties({
120+
...baseClaims,
121+
v: 2,
122+
fea: 'uo:impersonation,o:memberships,uo:feature3',
123+
o: {
124+
id: 'org_id',
125+
rol: 'admin',
126+
slg: 'org_slug',
127+
per: 'read,manage',
128+
fpm: '2,3,2',
129+
},
130+
});
131+
132+
expect(signedInAuthObject.orgPermissions?.sort()).toEqual(
133+
['org:impersonation:read', 'org:memberships:read', 'org:memberships:manage', 'org:feature3:read'].sort(),
134+
);
135+
});
136+
137+
test('if there is no o.fpm and o.per org permissions should be empty arrat', () => {
138+
const { sessionClaims: v2Claims, ...signedInAuthObject } = JWTPayloadToAuthObjectProperties({
139+
...baseClaims,
140+
v: 2,
141+
fea: 'u:impersonation,u:memberships,u:feature3',
142+
o: {
143+
id: 'org_id',
144+
rol: 'admin',
145+
slg: 'org_slug',
146+
},
147+
});
148+
149+
expect(signedInAuthObject.orgPermissions).toEqual([]);
150+
});
151+
152+
test('org role is prefixed with org:', () => {
153+
const { sessionClaims: v2Claims, ...signedInAuthObject } = JWTPayloadToAuthObjectProperties({
154+
...baseClaims,
155+
v: 2,
156+
fea: 'u:impersonation,u:memberships,u:feature3',
157+
o: {
158+
id: 'org_id',
159+
rol: 'admin',
160+
slg: 'org_slug',
161+
},
162+
});
163+
164+
expect(signedInAuthObject.orgRole).toBe('org:admin');
165+
});
166+
});

0 commit comments

Comments
 (0)