Skip to content

feat(types,shared): JWT v2 - Support new organization claims structure #5549

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Apr 9, 2025
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
392904d
feat(types): Organization structure for JWT v2
octoper Apr 7, 2025
d6dac37
feat(shared): Moved resolveSignedInAuthStateFromJWTClaims to shared p…
octoper Apr 7, 2025
856edcd
refactor(shared,types): Rename claim to
octoper Apr 7, 2025
502277a
refactor(shared): Replace if with a switch
octoper Apr 7, 2025
7653b70
refactor(shared,backend,clerk-js): Add __experimental_ prefix
octoper Apr 7, 2025
8a247df
chore(types,shared): Added tests and did some cleanup
octoper Apr 7, 2025
2498d7c
chore(shared): Rename test case
octoper Apr 7, 2025
057913c
chore(repo): Adds changeset
octoper Apr 7, 2025
1a3c152
chore(shared,types): Org permissions claims is a string in v2
octoper Apr 7, 2025
00935b4
refactor(clerk-js,shared,type): Support new claims and keep orgPermis…
octoper Apr 8, 2025
79c00e1
chore(shared,types): Move SharedSignedInAuthObjectProperties to @cler…
octoper Apr 8, 2025
7cc576a
fix(shared): Add org: prefix to org role
octoper Apr 8, 2025
ceab9e7
fix(shared): Add org: prefix to org roles to match previous behavior
octoper Apr 9, 2025
9e968f8
chore(clerk-js): Change bundlewatch limit
octoper Apr 9, 2025
1b59290
fix(shared): Only parse permissions if o.per and o.fpm are not undefined
octoper Apr 9, 2025
1937de1
fix(shared): Only handle org claims if o is present
octoper Apr 9, 2025
cd352aa
chore(backend): Use SharedSignedInAuthObjectProperties for SignedInAu…
octoper Apr 9, 2025
f982c48
chore(repo): Update changeset
octoper Apr 9, 2025
5f27918
chore(shared,types): Remove v from JWTPayloadBase as is not needed
octoper Apr 9, 2025
c1cb785
chore(clerk-js): Change bundlewatch limit
octoper Apr 9, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/cool-socks-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/shared': minor
'@clerk/types': minor
---

Adding the new `o` claim that contains all organization related info for JWT v2 schema
6 changes: 6 additions & 0 deletions .changeset/cyan-hairs-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/backend': patch
---

Uses the helper function `__experimental_JWTPayloadToAuthObjectProperties` from `@clerk/shared` to handle the new JWT v2 schema.
57 changes: 4 additions & 53 deletions packages/backend/src/tokens/authObjects.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { createCheckAuthorization } from '@clerk/shared/authorization';
import { __experimental_JWTPayloadToAuthObjectProperties } from '@clerk/shared/jwtPayloadParser';
import type {
ActClaim,
CheckAuthorizationFromSessionClaims,
JwtPayload,
OrganizationCustomPermissionKey,
OrganizationCustomRoleKey,
ServerGetToken,
ServerGetTokenOptions,
SessionStatusClaim,
SharedSignedInAuthObjectProperties,
} from '@clerk/types';

import type { CreateBackendApiOptions } from '../api';
Expand All @@ -27,28 +25,7 @@ export type SignedInAuthObjectOptions = CreateBackendApiOptions & {
/**
* @internal
*/
type SignedInAuthObjectProperties = {
sessionClaims: JwtPayload;
sessionId: string;
sessionStatus: SessionStatusClaim | null;
actor: ActClaim | undefined;
userId: string;
orgId: string | undefined;
orgRole: OrganizationCustomRoleKey | undefined;
orgSlug: string | undefined;
orgPermissions: OrganizationCustomPermissionKey[] | undefined;
/**
* Factor Verification Age
* Each item represents the minutes that have passed since the last time a first or second factor were verified.
* [fistFactorAge, secondFactorAge]
*/
factorVerificationAge: [firstFactorAge: number, secondFactorAge: number] | null;
};

/**
* @internal
*/
export type SignedInAuthObject = SignedInAuthObjectProperties & {
export type SignedInAuthObject = SharedSignedInAuthObjectProperties & {
getToken: ServerGetToken;
has: CheckAuthorizationFromSessionClaims;
debug: AuthObjectDebug;
Expand Down Expand Up @@ -92,31 +69,6 @@ const createDebug = (data: AuthObjectDebugData | undefined) => {
};
};

const generateSignedInAuthObjectProperties = (claims: JwtPayload): SignedInAuthObjectProperties => {
// fva can be undefined for instances that have not opt-in
const factorVerificationAge = claims.fva ?? null;

// sts can be undefined for instances that have not opt-in
const sessionStatus = claims.sts ?? null;

// TODO(jwt-v2): replace this when the new claim for org permissions is added, this will not break
// anything since the JWT v2 is not yet available
const orgPermissions = claims.org_permissions;

return {
sessionClaims: claims,
sessionId: claims.sid,
sessionStatus,
actor: claims.act,
userId: claims.sub,
orgId: claims.org_id,
orgRole: claims.org_role,
orgSlug: claims.org_slug,
orgPermissions,
factorVerificationAge,
};
};

/**
* @internal
*/
Expand All @@ -126,14 +78,13 @@ export function signedInAuthObject(
sessionClaims: JwtPayload,
): SignedInAuthObject {
const { actor, sessionId, sessionStatus, userId, orgId, orgRole, orgSlug, orgPermissions, factorVerificationAge } =
generateSignedInAuthObjectProperties(sessionClaims);
__experimental_JWTPayloadToAuthObjectProperties(sessionClaims);
const apiClient = createBackendApiClient(authenticateContext);
const getToken = createGetToken({
sessionId,
sessionToken,
fetcher: async (...args) => (await apiClient.sessions.getToken(...args)).jwt,
});

return {
actor,
sessionClaims,
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"files": [
{ "path": "./dist/clerk.js", "maxSize": "590kB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "72.7KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "75KB" },
{ "path": "./dist/clerk.headless*.js", "maxSize": "55KB" },
{ "path": "./dist/ui-common*.js", "maxSize": "98.2KB" },
{ "path": "./dist/vendors*.js", "maxSize": "36KB" },
Expand Down
33 changes: 17 additions & 16 deletions packages/clerk-js/src/core/jwt-client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { __experimental_JWTPayloadToAuthObjectProperties } from '@clerk/shared/jwtPayloadParser';
import type {
ClientJSON,
OrganizationMembershipJSON,
Expand Down Expand Up @@ -43,47 +44,47 @@ export function createClientFromJwt(jwt: string | undefined | null): Client {
} as unknown as ClientJSON);
}

const { sid, sub, org_id, org_role, org_permissions, org_slug, fva } = token.jwt.claims;

// TODO(jwt-v2): when JWT version 2 is available, we should use the new claims instead of the old ones
const { sessionId, userId, orgId, orgRole, orgPermissions, orgSlug, factorVerificationAge } =
__experimental_JWTPayloadToAuthObjectProperties(token.jwt.claims);

// TODO(jwt-v2): when JWT version 2 is available, we should revise org permissions
const defaultClient = {
object: 'client',
last_active_session_id: sid,
last_active_session_id: sessionId,
id: 'client_init',
sessions: [
{
object: 'session',
id: sid,
id: sessionId,
status: 'active',
last_active_organization_id: org_id || null,
last_active_organization_id: orgId || null,
// @ts-expect-error - ts is not happy about `id:undefined`, but this is allowed and expected
last_active_token: {
id: undefined,
object: 'token',
jwt,
} as TokenJSON,
factor_verification_age: fva || null,
factor_verification_age: factorVerificationAge || null,
public_user_data: {
user_id: sub,
user_id: userId,
} as PublicUserDataJSON,
user: {
object: 'user',
id: sub,
id: userId,
organization_memberships:
org_id && org_slug && org_role
orgId && orgSlug && orgRole
? [
{
object: 'organization_membership',
id: org_id,
role: org_role,
permissions: org_permissions || [],
id: orgId,
role: orgRole,
permissions: orgPermissions || [],
organization: {
object: 'organization',
id: org_id,
id: orgId,
// Use slug as name for the organization, since name is not available in the token.
name: org_slug,
slug: org_slug,
name: orgSlug,
slug: orgSlug,
members_count: 1,
max_allowed_memberships: 1,
},
Expand Down
3 changes: 2 additions & 1 deletion packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@
"web3",
"getEnvVariable",
"pathMatcher",
"organization"
"organization",
"jwtPayloadParser"
],
"scripts": {
"build": "tsup",
Expand Down
166 changes: 166 additions & 0 deletions packages/shared/src/__tests__/jwtPayloadParser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { __experimental_JWTPayloadToAuthObjectProperties as JWTPayloadToAuthObjectProperties } from '../jwtPayloadParser';

const baseClaims = {
exp: 1234567890,
iat: 1234567890,
iss: 'https://api.clerk.com',
sub: 'sub',
sid: 'sid',
azp: 'azp',
nbf: 1234567890,
__raw: '',
};

describe('JWTPayloadToAuthObjectProperties', () => {
test('auth object with JWT v2 does not produces anything org related if there is no org active', () => {
const { sessionClaims: v2Claims, ...signedInAuthObjectV2 } = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'u:impersonation,u:memberships',
});

const { sessionClaims: v1Claims, ...signedInAuthObjectV1 } = JWTPayloadToAuthObjectProperties({
...baseClaims,
});
expect(signedInAuthObjectV1).toEqual(signedInAuthObjectV2);
expect(signedInAuthObjectV1.orgId).toBeUndefined();
expect(signedInAuthObjectV1.orgPermissions).toBeUndefined();
expect(signedInAuthObjectV1.orgRole).toBeUndefined();
expect(signedInAuthObjectV1.orgSlug).toBeUndefined();
});

test('produced auth object is the same for v1 and v2', () => {
const { sessionClaims: v2Claims, ...signedInAuthObjectV2 } = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'o:impersonation',
o: {
id: 'org_xxxxxxx',
rol: 'admin',
slg: '/test',
per: 'read,manage',
fpm: '3',
},
});

const { sessionClaims: v1Claims, ...signedInAuthObjectV1 } = JWTPayloadToAuthObjectProperties({
...baseClaims,
org_id: 'org_xxxxxxx',
org_role: 'org:admin',
org_slug: '/test',
org_permissions: ['org:impersonation:read', 'org:impersonation:manage'],
});
expect(signedInAuthObjectV1).toEqual(signedInAuthObjectV2);
});

test('produced auth object is the same for v1 and v2', () => {
const { sessionClaims: v2Claims, ...signedInAuthObjectV2 } = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'o:impersonation',
o: {
id: 'org_xxxxxxx',
rol: 'admin',
slg: '/test',
per: 'read,manage',
fpm: '3',
},
});

const { sessionClaims: v1Claims, ...signedInAuthObjectV1 } = JWTPayloadToAuthObjectProperties({
...baseClaims,
org_id: 'org_xxxxxxx',
org_role: 'org:admin',
org_slug: '/test',
org_permissions: ['org:impersonation:read', 'org:impersonation:manage'],
});
expect(signedInAuthObjectV1).toEqual(signedInAuthObjectV2);
});

test('org permissions are generated correctly when fea, per, and fpm are present', () => {
const { sessionClaims: v2Claims, ...signedInAuthObject } = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'o:impersonation,o:memberships',
o: {
id: 'org_xxxxxxx',
rol: 'admin',
slg: '/test',
per: 'read,manage',
fpm: '2,3',
},
});

expect(signedInAuthObject.orgPermissions?.sort()).toEqual(
['org:impersonation:read', 'org:memberships:read', 'org:memberships:manage'].sort(),
);
});

test('if a feature is not mapped to any permissions it is added as is to the orgPermissions array', () => {
const { sessionClaims: v2Claims, ...signedInAuthObject } = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'o:impersonation,o:memberships,o:feature3',
o: {
id: 'org_id',
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '2,3',
},
});

expect(signedInAuthObject.orgPermissions?.sort()).toEqual(
['org:impersonation:read', 'org:memberships:read', 'org:memberships:manage'].sort(),
);
});

test('includes both org and user scoped features', () => {
const { sessionClaims: v2Claims, ...signedInAuthObject } = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'uo:impersonation,o:memberships,uo:feature3',
o: {
id: 'org_id',
rol: 'admin',
slg: 'org_slug',
per: 'read,manage',
fpm: '2,3,2',
},
});

expect(signedInAuthObject.orgPermissions?.sort()).toEqual(
['org:impersonation:read', 'org:memberships:read', 'org:memberships:manage', 'org:feature3:read'].sort(),
);
});

test('if there is no o.fpm and o.per org permissions should be empty arrat', () => {
const { sessionClaims: v2Claims, ...signedInAuthObject } = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'u:impersonation,u:memberships,u:feature3',
o: {
id: 'org_id',
rol: 'admin',
slg: 'org_slug',
},
});

expect(signedInAuthObject.orgPermissions).toEqual([]);
});

test('org role is prefixed with org:', () => {
const { sessionClaims: v2Claims, ...signedInAuthObject } = JWTPayloadToAuthObjectProperties({
...baseClaims,
v: 2,
fea: 'u:impersonation,u:memberships,u:feature3',
o: {
id: 'org_id',
rol: 'admin',
slg: 'org_slug',
},
});

expect(signedInAuthObject.orgRole).toBe('org:admin');
});
});
1 change: 1 addition & 0 deletions packages/shared/src/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
SignOut,
UseAuthReturn,
} from '@clerk/types';
import { permission } from 'process';

type TypesToConfig = Record<SessionVerificationTypes, Exclude<ReverificationConfig, SessionVerificationTypes>>;
type AuthorizationOptions = {
Expand Down
Loading