From 392904db61c085013fb2184afbec3cdf9a89d3fe Mon Sep 17 00:00:00 2001 From: Vaggelis Yfantis Date: Mon, 7 Apr 2025 12:15:29 +0300 Subject: [PATCH 01/20] feat(types): Organization structure for JWT v2 --- packages/types/src/jwtv2.ts | 53 +++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/packages/types/src/jwtv2.ts b/packages/types/src/jwtv2.ts index 65e5ca59bb1..548941a3822 100644 --- a/packages/types/src/jwtv2.ts +++ b/packages/types/src/jwtv2.ts @@ -97,21 +97,6 @@ type JWTPayloadBase = { */ fva?: [fistFactorAge: number, secondFactorAge: number]; - /** - * Active organization ID. - */ - org_id?: string; - - /** - * Active organization slug. - */ - org_slug?: string; - - /** - * Active organization role. - */ - org_role?: OrganizationCustomRoleKey; - /** * Session status */ @@ -137,6 +122,21 @@ export type VersionedJwtPayload = * Active organization permissions. */ org_permissions?: OrganizationCustomPermissionKey[]; + + /** + * Active organization ID. + */ + org_id?: string; + + /** + * Active organization slug. + */ + org_slug?: string; + + /** + * Active organization role. + */ + org_role?: OrganizationCustomRoleKey; } | { /** @@ -146,8 +146,27 @@ export type VersionedJwtPayload = */ ver: 2; - org_permissions?: never; - // TODO: include the version 2 claims here + /** + * @experimental - This structure is subject to change. + * + * Active organization information. + */ + org?: { + /** + * Active organization ID. + */ + id: string; + + /** + * Active organization slug. + */ + slg?: string; + + /** + * Active organization role. + */ + rol?: OrganizationCustomRoleKey; + }; }; export type JwtPayload = JWTPayloadBase & CustomJwtSessionClaims & VersionedJwtPayload; From d6dac3732eda01cb963103cb7059fe3984383e94 Mon Sep 17 00:00:00 2001 From: Vaggelis Yfantis Date: Mon, 7 Apr 2025 12:42:55 +0300 Subject: [PATCH 02/20] feat(shared): Moved resolveSignedInAuthStateFromJWTClaims to shared package --- packages/backend/src/tokens/authObjects.ts | 29 +-------- packages/clerk-js/src/core/jwt-client.ts | 33 +++++----- packages/shared/src/authorization.ts | 73 +++++++++++++++++++++- 3 files changed, 91 insertions(+), 44 deletions(-) diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index 8f652eadfd3..b7fc2aa3aa6 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -1,4 +1,4 @@ -import { createCheckAuthorization } from '@clerk/shared/authorization'; +import { createCheckAuthorization, resolveSignedInAuthStateFromJWTClaims } from '@clerk/shared/authorization'; import type { ActClaim, CheckAuthorizationFromSessionClaims, @@ -92,31 +92,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 */ @@ -126,7 +101,7 @@ export function signedInAuthObject( sessionClaims: JwtPayload, ): SignedInAuthObject { const { actor, sessionId, sessionStatus, userId, orgId, orgRole, orgSlug, orgPermissions, factorVerificationAge } = - generateSignedInAuthObjectProperties(sessionClaims); + resolveSignedInAuthStateFromJWTClaims(sessionClaims); const apiClient = createBackendApiClient(authenticateContext); const getToken = createGetToken({ sessionId, diff --git a/packages/clerk-js/src/core/jwt-client.ts b/packages/clerk-js/src/core/jwt-client.ts index 2459617e2b2..bc82a615b14 100644 --- a/packages/clerk-js/src/core/jwt-client.ts +++ b/packages/clerk-js/src/core/jwt-client.ts @@ -1,3 +1,4 @@ +import { resolveSignedInAuthStateFromJWTClaims } from '@clerk/shared/authorization'; import type { ClientJSON, OrganizationMembershipJSON, @@ -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 } = + resolveSignedInAuthStateFromJWTClaims(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, }, diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index a49da464b3c..bba84f1955c 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -2,6 +2,7 @@ import type { ActClaim, CheckAuthorizationWithCustomPermissions, GetToken, + JwtPayload, OrganizationCustomPermissionKey, OrganizationCustomRoleKey, PendingSessionOptions, @@ -272,4 +273,74 @@ const resolveAuthState = ({ } }; -export { createCheckAuthorization, validateReverificationConfig, resolveAuthState }; +/** + * @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; +}; + +/** + * @experimental + * + * Resolves the signed-in auth state from JWT claims. + */ +const resolveSignedInAuthStateFromJWTClaims = (claims: JwtPayload): SignedInAuthObjectProperties => { + let orgId: string | undefined; + let orgRole: OrganizationCustomRoleKey | undefined; + let orgSlug: string | undefined; + + // 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.ver === 2 ? undefined : claims.org_permissions; + + if (claims.ver === 2) { + orgId = claims.org?.id; + orgRole = claims.org?.rol; + orgSlug = claims.org?.slg; + } else { + orgId = claims.org_id; + orgRole = claims.org_role; + orgSlug = claims.org_slug; + } + + return { + sessionClaims: claims, + sessionId: claims.sid, + sessionStatus, + actor: claims.act, + userId: claims.sub, + orgId: orgId, + orgRole: orgRole, + orgSlug: orgSlug, + orgPermissions, + factorVerificationAge, + }; +}; + +export { + createCheckAuthorization, + validateReverificationConfig, + resolveAuthState, + resolveSignedInAuthStateFromJWTClaims, +}; From 856edcd0c2cfde1514036b8a3b13abee50f063b4 Mon Sep 17 00:00:00 2001 From: Vaggelis Yfantis Date: Mon, 7 Apr 2025 12:55:57 +0300 Subject: [PATCH 03/20] refactor(shared,types): Rename claim to --- packages/shared/src/authorization.ts | 4 ++-- packages/types/src/jwtv2.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index bba84f1955c..70bded2c3a4 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -312,9 +312,9 @@ const resolveSignedInAuthStateFromJWTClaims = (claims: JwtPayload): SignedInAuth // 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.ver === 2 ? undefined : claims.org_permissions; + const orgPermissions = claims.v === 2 ? undefined : claims.org_permissions; - if (claims.ver === 2) { + if (claims.v === 2) { orgId = claims.org?.id; orgRole = claims.org?.rol; orgSlug = claims.org?.slg; diff --git a/packages/types/src/jwtv2.ts b/packages/types/src/jwtv2.ts index 548941a3822..498c847dc07 100644 --- a/packages/types/src/jwtv2.ts +++ b/packages/types/src/jwtv2.ts @@ -115,7 +115,7 @@ export type VersionedJwtPayload = * * The version of the JWT payload. */ - ver?: never; + v?: never; /** * @@ -144,7 +144,7 @@ export type VersionedJwtPayload = * * The version of the JWT payload. */ - ver: 2; + v: 2; /** * @experimental - This structure is subject to change. From 502277ae345b3f50a6211cb60bced9140ccc4646 Mon Sep 17 00:00:00 2001 From: Vaggelis Yfantis Date: Mon, 7 Apr 2025 13:54:50 +0300 Subject: [PATCH 04/20] refactor(shared): Replace if with a switch --- packages/shared/src/authorization.ts | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index 70bded2c3a4..f19f3cf1d7a 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -303,6 +303,7 @@ const resolveSignedInAuthStateFromJWTClaims = (claims: JwtPayload): SignedInAuth let orgId: string | undefined; let orgRole: OrganizationCustomRoleKey | undefined; let orgSlug: string | undefined; + let orgPermissions: string[] | undefined; // fva can be undefined for instances that have not opt-in const factorVerificationAge = claims.fva ?? null; @@ -310,18 +311,21 @@ const resolveSignedInAuthStateFromJWTClaims = (claims: JwtPayload): SignedInAuth // 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.v === 2 ? undefined : claims.org_permissions; - - if (claims.v === 2) { - orgId = claims.org?.id; - orgRole = claims.org?.rol; - orgSlug = claims.org?.slg; - } else { - orgId = claims.org_id; - orgRole = claims.org_role; - orgSlug = claims.org_slug; + switch (claims.v) { + case 2: + orgId = claims.org?.id; + orgRole = claims.org?.rol; + orgSlug = claims.org?.slg; + + // TODO(jwt-v2): when JWT version 2 is available, do proper handling for org permissions + orgPermissions = (claims?.org_permissions as string[] | undefined) ?? undefined; + break; + default: + orgId = claims.org_id; + orgRole = claims.org_role; + orgSlug = claims.org_slug; + orgPermissions = claims.org_permissions; + break; } return { From 7653b70fee5fc672fac6e9c229eac4e54e405515 Mon Sep 17 00:00:00 2001 From: Vaggelis Yfantis Date: Mon, 7 Apr 2025 14:28:28 +0300 Subject: [PATCH 05/20] refactor(shared,backend,clerk-js): Add __experimental_ prefix --- packages/backend/src/tokens/authObjects.ts | 7 +++++-- packages/clerk-js/src/core/jwt-client.ts | 4 ++-- packages/shared/src/authorization.ts | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index b7fc2aa3aa6..99c0b2e9c97 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -1,4 +1,7 @@ -import { createCheckAuthorization, resolveSignedInAuthStateFromJWTClaims } from '@clerk/shared/authorization'; +import { + __experimental_resolveSignedInAuthStateFromJWTClaims, + createCheckAuthorization, +} from '@clerk/shared/authorization'; import type { ActClaim, CheckAuthorizationFromSessionClaims, @@ -101,7 +104,7 @@ export function signedInAuthObject( sessionClaims: JwtPayload, ): SignedInAuthObject { const { actor, sessionId, sessionStatus, userId, orgId, orgRole, orgSlug, orgPermissions, factorVerificationAge } = - resolveSignedInAuthStateFromJWTClaims(sessionClaims); + __experimental_resolveSignedInAuthStateFromJWTClaims(sessionClaims); const apiClient = createBackendApiClient(authenticateContext); const getToken = createGetToken({ sessionId, diff --git a/packages/clerk-js/src/core/jwt-client.ts b/packages/clerk-js/src/core/jwt-client.ts index bc82a615b14..8ca5faf9012 100644 --- a/packages/clerk-js/src/core/jwt-client.ts +++ b/packages/clerk-js/src/core/jwt-client.ts @@ -1,4 +1,4 @@ -import { resolveSignedInAuthStateFromJWTClaims } from '@clerk/shared/authorization'; +import { __experimental_resolveSignedInAuthStateFromJWTClaims } from '@clerk/shared/authorization'; import type { ClientJSON, OrganizationMembershipJSON, @@ -45,7 +45,7 @@ export function createClientFromJwt(jwt: string | undefined | null): Client { } const { sessionId, userId, orgId, orgRole, orgPermissions, orgSlug, factorVerificationAge } = - resolveSignedInAuthStateFromJWTClaims(token.jwt.claims); + __experimental_resolveSignedInAuthStateFromJWTClaims(token.jwt.claims); // TODO(jwt-v2): when JWT version 2 is available, we should revise org permissions const defaultClient = { diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index f19f3cf1d7a..f607f53f316 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -299,7 +299,7 @@ type SignedInAuthObjectProperties = { * * Resolves the signed-in auth state from JWT claims. */ -const resolveSignedInAuthStateFromJWTClaims = (claims: JwtPayload): SignedInAuthObjectProperties => { +const __experimental_resolveSignedInAuthStateFromJWTClaims = (claims: JwtPayload): SignedInAuthObjectProperties => { let orgId: string | undefined; let orgRole: OrganizationCustomRoleKey | undefined; let orgSlug: string | undefined; @@ -346,5 +346,5 @@ export { createCheckAuthorization, validateReverificationConfig, resolveAuthState, - resolveSignedInAuthStateFromJWTClaims, + __experimental_resolveSignedInAuthStateFromJWTClaims, }; From 8a247dfa11ebe98297b801e1caad1ce8f8312848 Mon Sep 17 00:00:00 2001 From: Vaggelis Yfantis Date: Mon, 7 Apr 2025 15:47:16 +0300 Subject: [PATCH 06/20] chore(types,shared): Added tests and did some cleanup --- packages/backend/src/tokens/authObjects.ts | 1 - .../src/__tests__/authorization.test.ts | 49 +++++++++++++++++++ packages/shared/src/authorization.ts | 3 +- packages/types/src/jwtv2.ts | 21 +++----- 4 files changed, 58 insertions(+), 16 deletions(-) create mode 100644 packages/shared/src/__tests__/authorization.test.ts diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index 99c0b2e9c97..accffba3db3 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -111,7 +111,6 @@ export function signedInAuthObject( sessionToken, fetcher: async (...args) => (await apiClient.sessions.getToken(...args)).jwt, }); - return { actor, sessionClaims, diff --git a/packages/shared/src/__tests__/authorization.test.ts b/packages/shared/src/__tests__/authorization.test.ts new file mode 100644 index 00000000000..f840ffb02c3 --- /dev/null +++ b/packages/shared/src/__tests__/authorization.test.ts @@ -0,0 +1,49 @@ +import { __experimental_resolveSignedInAuthStateFromJWTClaims as resolveSignedInAuthStateFromJWTClaims } from '../authorization'; + +describe('resolveSignedInAuthStateFromJWTClaims', () => { + const baseClaims = { + exp: 1234567890, + iat: 1234567890, + iss: 'https://api.clerk.com', + sub: 'sub', + sid: 'sid', + azp: 'azp', + nbf: 1234567890, + __raw: '', + }; + + test('produced auth object with v2 matches v1', () => { + const { sessionClaims: v2Claims, ...signedInAuthObjectV2 } = resolveSignedInAuthStateFromJWTClaims({ + ...baseClaims, + v: 2, + org: { + id: 'org_id', + rol: 'admin', + slg: 'org_slug', + per: ['permission1', 'permission2'], + }, + }); + + const { sessionClaims: v1Claims, ...signedInAuthObjectV1 } = resolveSignedInAuthStateFromJWTClaims({ + ...baseClaims, + org_id: 'org_id', + org_role: 'admin', + org_slug: 'org_slug', + org_permissions: ['permission1', 'permission2'], + v: undefined, + }); + expect(signedInAuthObjectV1).toMatchObject(signedInAuthObjectV2); + }); + + test('produced auth object with v2 matches v1 without having orgs', () => { + const { sessionClaims: v2Claims, ...signedInAuthObjectV2 } = resolveSignedInAuthStateFromJWTClaims({ + ...baseClaims, + v: 2, + }); + + const { sessionClaims: v1Claims, ...signedInAuthObjectV1 } = resolveSignedInAuthStateFromJWTClaims({ + ...baseClaims, + }); + expect(signedInAuthObjectV1).toMatchObject(signedInAuthObjectV2); + }); +}); diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index f607f53f316..ad6687f0bba 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -317,8 +317,7 @@ const __experimental_resolveSignedInAuthStateFromJWTClaims = (claims: JwtPayload orgRole = claims.org?.rol; orgSlug = claims.org?.slg; - // TODO(jwt-v2): when JWT version 2 is available, do proper handling for org permissions - orgPermissions = (claims?.org_permissions as string[] | undefined) ?? undefined; + orgPermissions = claims.org?.per; break; default: orgId = claims.org_id; diff --git a/packages/types/src/jwtv2.ts b/packages/types/src/jwtv2.ts index 498c847dc07..b3d78bf7e4f 100644 --- a/packages/types/src/jwtv2.ts +++ b/packages/types/src/jwtv2.ts @@ -43,7 +43,7 @@ type JWTPayloadBase = { * * The version of the JWT payload. */ - ver: number | undefined; + v?: number | undefined; /** * Encoded token supporting the `getRawString` method. @@ -110,12 +110,7 @@ type JWTPayloadBase = { export type VersionedJwtPayload = | { - /** - * @experimental - * - * The version of the JWT payload. - */ - v?: never; + v?: undefined; /** * @@ -139,13 +134,7 @@ export type VersionedJwtPayload = org_role?: OrganizationCustomRoleKey; } | { - /** - * @experimental - * - * The version of the JWT payload. - */ v: 2; - /** * @experimental - This structure is subject to change. * @@ -166,6 +155,12 @@ export type VersionedJwtPayload = * Active organization role. */ rol?: OrganizationCustomRoleKey; + + /** + * + * Active organization permissions. + */ + per?: OrganizationCustomPermissionKey[]; }; }; From 2498d7c17c452de439ab9be09646b6d9c94f85fc Mon Sep 17 00:00:00 2001 From: Vaggelis Yfantis Date: Mon, 7 Apr 2025 15:51:54 +0300 Subject: [PATCH 07/20] chore(shared): Rename test case --- packages/shared/src/__tests__/authorization.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/shared/src/__tests__/authorization.test.ts b/packages/shared/src/__tests__/authorization.test.ts index f840ffb02c3..66e4ee2cf91 100644 --- a/packages/shared/src/__tests__/authorization.test.ts +++ b/packages/shared/src/__tests__/authorization.test.ts @@ -30,12 +30,11 @@ describe('resolveSignedInAuthStateFromJWTClaims', () => { org_role: 'admin', org_slug: 'org_slug', org_permissions: ['permission1', 'permission2'], - v: undefined, }); expect(signedInAuthObjectV1).toMatchObject(signedInAuthObjectV2); }); - test('produced auth object with v2 matches v1 without having orgs', () => { + test('produced auth object with v2 matches v1 without having orgs related claims', () => { const { sessionClaims: v2Claims, ...signedInAuthObjectV2 } = resolveSignedInAuthStateFromJWTClaims({ ...baseClaims, v: 2, From 057913c40c0faeb976dcf52af0cb7bd84f724556 Mon Sep 17 00:00:00 2001 From: Vaggelis Yfantis Date: Mon, 7 Apr 2025 16:00:48 +0300 Subject: [PATCH 08/20] chore(repo): Adds changeset --- .changeset/cool-socks-invite.md | 6 ++++++ .changeset/cyan-hairs-share.md | 6 ++++++ 2 files changed, 12 insertions(+) create mode 100644 .changeset/cool-socks-invite.md create mode 100644 .changeset/cyan-hairs-share.md diff --git a/.changeset/cool-socks-invite.md b/.changeset/cool-socks-invite.md new file mode 100644 index 00000000000..7bc55b1fa4c --- /dev/null +++ b/.changeset/cool-socks-invite.md @@ -0,0 +1,6 @@ +--- +'@clerk/shared': minor +'@clerk/types': minor +--- + +Added new `org` claim for JWT v2 schema diff --git a/.changeset/cyan-hairs-share.md b/.changeset/cyan-hairs-share.md new file mode 100644 index 00000000000..50330e5c9df --- /dev/null +++ b/.changeset/cyan-hairs-share.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/backend': patch +--- + +Uses the `__experimental_resolveSignedInAuthStateFromJWTClaims` from `@clerk/shared` to handle the new JWT v2 schema From 1a3c152bc975f96d4012548e7c4d8509827f8556 Mon Sep 17 00:00:00 2001 From: Vaggelis Yfantis Date: Mon, 7 Apr 2025 17:51:41 +0300 Subject: [PATCH 09/20] chore(shared,types): Org permissions claims is a string in v2 --- .../src/__tests__/authorization.test.ts | 32 +++++++++++++++++-- packages/shared/src/authorization.ts | 8 ++--- packages/types/src/jwtv2.ts | 2 +- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/__tests__/authorization.test.ts b/packages/shared/src/__tests__/authorization.test.ts index 66e4ee2cf91..106d922d3b9 100644 --- a/packages/shared/src/__tests__/authorization.test.ts +++ b/packages/shared/src/__tests__/authorization.test.ts @@ -20,7 +20,7 @@ describe('resolveSignedInAuthStateFromJWTClaims', () => { id: 'org_id', rol: 'admin', slg: 'org_slug', - per: ['permission1', 'permission2'], + per: 'permission1,permission2', }, }); @@ -31,7 +31,7 @@ describe('resolveSignedInAuthStateFromJWTClaims', () => { org_slug: 'org_slug', org_permissions: ['permission1', 'permission2'], }); - expect(signedInAuthObjectV1).toMatchObject(signedInAuthObjectV2); + expect(signedInAuthObjectV1).toEqual(signedInAuthObjectV2); }); test('produced auth object with v2 matches v1 without having orgs related claims', () => { @@ -43,6 +43,32 @@ describe('resolveSignedInAuthStateFromJWTClaims', () => { const { sessionClaims: v1Claims, ...signedInAuthObjectV1 } = resolveSignedInAuthStateFromJWTClaims({ ...baseClaims, }); - expect(signedInAuthObjectV1).toMatchObject(signedInAuthObjectV2); + expect(signedInAuthObjectV1).toEqual(signedInAuthObjectV2); + }); + + test('v2 org permissions are splitted correctly', () => { + const authObject = resolveSignedInAuthStateFromJWTClaims({ + ...baseClaims, + v: 2, + org: { + id: 'org_id', + rol: 'admin', + slg: 'org_slug', + per: 'permission1,permission2', + }, + }); + expect(authObject.orgPermissions).toEqual(['permission1', 'permission2']); + + const authObject2 = resolveSignedInAuthStateFromJWTClaims({ + ...baseClaims, + v: 2, + org: { + id: 'org_id', + rol: 'admin', + slg: 'org_slug', + per: 'permission1', + }, + }); + expect(authObject2.orgPermissions).toEqual(['permission1']); }); }); diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index ad6687f0bba..40b87a29596 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -303,7 +303,7 @@ const __experimental_resolveSignedInAuthStateFromJWTClaims = (claims: JwtPayload let orgId: string | undefined; let orgRole: OrganizationCustomRoleKey | undefined; let orgSlug: string | undefined; - let orgPermissions: string[] | undefined; + let orgPermissions: OrganizationCustomPermissionKey[] | undefined; // fva can be undefined for instances that have not opt-in const factorVerificationAge = claims.fva ?? null; @@ -312,13 +312,13 @@ const __experimental_resolveSignedInAuthStateFromJWTClaims = (claims: JwtPayload const sessionStatus = claims.sts ?? null; switch (claims.v) { - case 2: + case 2: { orgId = claims.org?.id; orgRole = claims.org?.rol; orgSlug = claims.org?.slg; - - orgPermissions = claims.org?.per; + orgPermissions = claims.org?.per?.split(',').map((permission: string) => permission.trim()) || undefined; break; + } default: orgId = claims.org_id; orgRole = claims.org_role; diff --git a/packages/types/src/jwtv2.ts b/packages/types/src/jwtv2.ts index b3d78bf7e4f..0e826ff19b3 100644 --- a/packages/types/src/jwtv2.ts +++ b/packages/types/src/jwtv2.ts @@ -160,7 +160,7 @@ export type VersionedJwtPayload = * * Active organization permissions. */ - per?: OrganizationCustomPermissionKey[]; + per?: string; }; }; From 00935b422b768b0ea713e1f69df9d530c13f4057 Mon Sep 17 00:00:00 2001 From: Vaggelis Yfantis Date: Tue, 8 Apr 2025 15:14:17 +0300 Subject: [PATCH 10/20] refactor(clerk-js,shared,type): Support new claims and keep orgPermissions on authObject backwards compatible --- packages/backend/src/tokens/authObjects.ts | 8 +- packages/clerk-js/src/core/jwt-client.ts | 4 +- packages/shared/package.json | 3 +- .../src/__tests__/authorization.test.ts | 74 --------- .../src/__tests__/jwtPayloadParser.test.ts | 108 ++++++++++++ packages/shared/src/authorization.ts | 77 +-------- packages/shared/src/jwtPayloadParser.ts | 156 ++++++++++++++++++ packages/types/src/jwtv2.ts | 19 ++- 8 files changed, 290 insertions(+), 159 deletions(-) delete mode 100644 packages/shared/src/__tests__/authorization.test.ts create mode 100644 packages/shared/src/__tests__/jwtPayloadParser.test.ts create mode 100644 packages/shared/src/jwtPayloadParser.ts diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index accffba3db3..a6a5ad23c3b 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -1,7 +1,5 @@ -import { - __experimental_resolveSignedInAuthStateFromJWTClaims, - createCheckAuthorization, -} from '@clerk/shared/authorization'; +import { createCheckAuthorization } from '@clerk/shared/authorization'; +import { __experimental_JWTPayloadToAuthObjectProperties } from '@clerk/shared/jwtPayloadParser'; import type { ActClaim, CheckAuthorizationFromSessionClaims, @@ -104,7 +102,7 @@ export function signedInAuthObject( sessionClaims: JwtPayload, ): SignedInAuthObject { const { actor, sessionId, sessionStatus, userId, orgId, orgRole, orgSlug, orgPermissions, factorVerificationAge } = - __experimental_resolveSignedInAuthStateFromJWTClaims(sessionClaims); + __experimental_JWTPayloadToAuthObjectProperties(sessionClaims); const apiClient = createBackendApiClient(authenticateContext); const getToken = createGetToken({ sessionId, diff --git a/packages/clerk-js/src/core/jwt-client.ts b/packages/clerk-js/src/core/jwt-client.ts index 8ca5faf9012..7de4e84f84f 100644 --- a/packages/clerk-js/src/core/jwt-client.ts +++ b/packages/clerk-js/src/core/jwt-client.ts @@ -1,4 +1,4 @@ -import { __experimental_resolveSignedInAuthStateFromJWTClaims } from '@clerk/shared/authorization'; +import { __experimental_JWTPayloadToAuthObjectProperties } from '@clerk/shared/jwtPayloadParser'; import type { ClientJSON, OrganizationMembershipJSON, @@ -45,7 +45,7 @@ export function createClientFromJwt(jwt: string | undefined | null): Client { } const { sessionId, userId, orgId, orgRole, orgPermissions, orgSlug, factorVerificationAge } = - __experimental_resolveSignedInAuthStateFromJWTClaims(token.jwt.claims); + __experimental_JWTPayloadToAuthObjectProperties(token.jwt.claims); // TODO(jwt-v2): when JWT version 2 is available, we should revise org permissions const defaultClient = { diff --git a/packages/shared/package.json b/packages/shared/package.json index cb079b9a38a..2741688d391 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -117,7 +117,8 @@ "web3", "getEnvVariable", "pathMatcher", - "organization" + "organization", + "jwtPayloadParser" ], "scripts": { "build": "tsup", diff --git a/packages/shared/src/__tests__/authorization.test.ts b/packages/shared/src/__tests__/authorization.test.ts deleted file mode 100644 index 106d922d3b9..00000000000 --- a/packages/shared/src/__tests__/authorization.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { __experimental_resolveSignedInAuthStateFromJWTClaims as resolveSignedInAuthStateFromJWTClaims } from '../authorization'; - -describe('resolveSignedInAuthStateFromJWTClaims', () => { - const baseClaims = { - exp: 1234567890, - iat: 1234567890, - iss: 'https://api.clerk.com', - sub: 'sub', - sid: 'sid', - azp: 'azp', - nbf: 1234567890, - __raw: '', - }; - - test('produced auth object with v2 matches v1', () => { - const { sessionClaims: v2Claims, ...signedInAuthObjectV2 } = resolveSignedInAuthStateFromJWTClaims({ - ...baseClaims, - v: 2, - org: { - id: 'org_id', - rol: 'admin', - slg: 'org_slug', - per: 'permission1,permission2', - }, - }); - - const { sessionClaims: v1Claims, ...signedInAuthObjectV1 } = resolveSignedInAuthStateFromJWTClaims({ - ...baseClaims, - org_id: 'org_id', - org_role: 'admin', - org_slug: 'org_slug', - org_permissions: ['permission1', 'permission2'], - }); - expect(signedInAuthObjectV1).toEqual(signedInAuthObjectV2); - }); - - test('produced auth object with v2 matches v1 without having orgs related claims', () => { - const { sessionClaims: v2Claims, ...signedInAuthObjectV2 } = resolveSignedInAuthStateFromJWTClaims({ - ...baseClaims, - v: 2, - }); - - const { sessionClaims: v1Claims, ...signedInAuthObjectV1 } = resolveSignedInAuthStateFromJWTClaims({ - ...baseClaims, - }); - expect(signedInAuthObjectV1).toEqual(signedInAuthObjectV2); - }); - - test('v2 org permissions are splitted correctly', () => { - const authObject = resolveSignedInAuthStateFromJWTClaims({ - ...baseClaims, - v: 2, - org: { - id: 'org_id', - rol: 'admin', - slg: 'org_slug', - per: 'permission1,permission2', - }, - }); - expect(authObject.orgPermissions).toEqual(['permission1', 'permission2']); - - const authObject2 = resolveSignedInAuthStateFromJWTClaims({ - ...baseClaims, - v: 2, - org: { - id: 'org_id', - rol: 'admin', - slg: 'org_slug', - per: 'permission1', - }, - }); - expect(authObject2.orgPermissions).toEqual(['permission1']); - }); -}); diff --git a/packages/shared/src/__tests__/jwtPayloadParser.test.ts b/packages/shared/src/__tests__/jwtPayloadParser.test.ts new file mode 100644 index 00000000000..30820411f1b --- /dev/null +++ b/packages/shared/src/__tests__/jwtPayloadParser.test.ts @@ -0,0 +1,108 @@ +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('resolveSignedInAuthStateFromJWTClaims', () => { + test.skip('produced auth object is the same for v1 and v2', () => { + const { sessionClaims: v2Claims, ...signedInAuthObjectV2 } = JWTPayloadToAuthObjectProperties({ + ...baseClaims, + v: 2, + o: { + id: 'org_id', + rol: 'admin', + slg: 'org_slug', + per: 'permission1,permission2', + }, + }); + + const { sessionClaims: v1Claims, ...signedInAuthObjectV1 } = JWTPayloadToAuthObjectProperties({ + ...baseClaims, + org_id: 'org_id', + org_role: 'admin', + org_slug: 'org_slug', + org_permissions: ['org:permission1', 'permission2'], + }); + 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_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('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('feature are user scoped only', () => { + 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?.sort()).toEqual([]); + }); +}); diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index 40b87a29596..eec1d7a05f5 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -2,7 +2,6 @@ import type { ActClaim, CheckAuthorizationWithCustomPermissions, GetToken, - JwtPayload, OrganizationCustomPermissionKey, OrganizationCustomRoleKey, PendingSessionOptions, @@ -13,6 +12,7 @@ import type { SignOut, UseAuthReturn, } from '@clerk/types'; +import { permission } from 'process'; type TypesToConfig = Record>; type AuthorizationOptions = { @@ -273,77 +273,4 @@ const resolveAuthState = ({ } }; -/** - * @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; -}; - -/** - * @experimental - * - * Resolves the signed-in auth state from JWT claims. - */ -const __experimental_resolveSignedInAuthStateFromJWTClaims = (claims: JwtPayload): SignedInAuthObjectProperties => { - let orgId: string | undefined; - let orgRole: OrganizationCustomRoleKey | undefined; - let orgSlug: string | undefined; - let orgPermissions: OrganizationCustomPermissionKey[] | undefined; - - // 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; - - switch (claims.v) { - case 2: { - orgId = claims.org?.id; - orgRole = claims.org?.rol; - orgSlug = claims.org?.slg; - orgPermissions = claims.org?.per?.split(',').map((permission: string) => permission.trim()) || undefined; - break; - } - default: - orgId = claims.org_id; - orgRole = claims.org_role; - orgSlug = claims.org_slug; - orgPermissions = claims.org_permissions; - break; - } - - return { - sessionClaims: claims, - sessionId: claims.sid, - sessionStatus, - actor: claims.act, - userId: claims.sub, - orgId: orgId, - orgRole: orgRole, - orgSlug: orgSlug, - orgPermissions, - factorVerificationAge, - }; -}; - -export { - createCheckAuthorization, - validateReverificationConfig, - resolveAuthState, - __experimental_resolveSignedInAuthStateFromJWTClaims, -}; +export { createCheckAuthorization, validateReverificationConfig, resolveAuthState }; diff --git a/packages/shared/src/jwtPayloadParser.ts b/packages/shared/src/jwtPayloadParser.ts new file mode 100644 index 00000000000..60aad00aaba --- /dev/null +++ b/packages/shared/src/jwtPayloadParser.ts @@ -0,0 +1,156 @@ +import type { + ActClaim, + JwtPayload, + OrganizationCustomPermissionKey, + OrganizationCustomRoleKey, + SessionStatusClaim, +} from '@clerk/types'; + +/** + * @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; +}; + +const parseFeatures = (fea: string | undefined) => { + const features = fea ? fea.split(',').map(f => f.trim()) : []; + + // TODO: make this more efficient + return { + orgFeatures: features.filter(f => f.includes('o')).map(f => f.split(':')[1]), + userFeatures: features.filter(f => f.includes('u')).map(f => f.split(':')[1]), + }; +}; + +const parsePermissions = ({ per, fpm }: { per?: string; fpm?: string }) => { + const permissions = per ? per.split(',').map(p => p.trim()) : []; + + // TODO: make this more efficient + const featurePermissionMap = fpm + ? fpm + .split(',') + .map(permission => parseInt(permission.trim(), 10)) + .map((permission: number) => + permission + .toString(2) + .padStart(permissions.length, '0') + .split('') + .map(bit => parseInt(bit, 10)), + ) + .filter(Boolean) + : []; + + return { permissions, featurePermissionMap }; +}; + +function buildOrgPermissions({ + features, + permissions, + featurePermissionMap, +}: { + features?: string[]; + permissions?: string[]; + featurePermissionMap?: number[][]; +}) { + // Early return if any required input is missing + if (!features || !permissions || !featurePermissionMap) { + return []; + } + + const orgPermissions: string[] = []; + + // Process each feature and its permissions in a single loop + for (let featureIndex = 0; featureIndex < features.length; featureIndex++) { + const feature = features[featureIndex]; + + if (featureIndex >= featurePermissionMap.length) { + continue; + } + + const permissionBits = featurePermissionMap[featureIndex]; + if (!permissionBits) continue; + + for (let permIndex = 0; permIndex < permissionBits.length; permIndex++) { + if (permissionBits[permIndex] === 1) { + orgPermissions.push(`org:${feature}:${permissions[permIndex]}`); + } + } + } + + return orgPermissions; +} + +/** + * @experimental + * + * Resolves the signed-in auth state from JWT claims. + */ +const __experimental_JWTPayloadToAuthObjectProperties = (claims: JwtPayload): SignedInAuthObjectProperties => { + let orgId: string | undefined; + let orgRole: OrganizationCustomRoleKey | undefined; + let orgSlug: string | undefined; + let orgPermissions: OrganizationCustomPermissionKey[] | undefined; + + // 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; + + switch (claims.v) { + case 2: { + orgId = claims.o?.id; + orgRole = claims.o?.rol; + orgSlug = claims.o?.slg; + + const { orgFeatures } = parseFeatures(claims.fea); + const { permissions, featurePermissionMap } = parsePermissions({ + per: claims.o?.per ?? '', + fpm: claims.o?.fpm, + }); + + orgPermissions = buildOrgPermissions({ + features: orgFeatures, + featurePermissionMap: featurePermissionMap, + permissions: permissions, + }); + break; + } + default: + orgId = claims.org_id; + orgRole = claims.org_role; + orgSlug = claims.org_slug; + orgPermissions = claims.org_permissions; + break; + } + + return { + sessionClaims: claims, + sessionId: claims.sid, + sessionStatus, + actor: claims.act, + userId: claims.sub, + orgId: orgId, + orgRole: orgRole, + orgSlug: orgSlug, + orgPermissions, + factorVerificationAge, + }; +}; + +export { __experimental_JWTPayloadToAuthObjectProperties }; diff --git a/packages/types/src/jwtv2.ts b/packages/types/src/jwtv2.ts index 0e826ff19b3..5a8495bc270 100644 --- a/packages/types/src/jwtv2.ts +++ b/packages/types/src/jwtv2.ts @@ -135,12 +135,18 @@ export type VersionedJwtPayload = } | { v: 2; + + /** + * Features for session. + */ + fea?: string; + /** * @experimental - This structure is subject to change. * * Active organization information. */ - org?: { + o?: { /** * Active organization ID. */ @@ -157,11 +163,20 @@ export type VersionedJwtPayload = rol?: OrganizationCustomRoleKey; /** - * * Active organization permissions. */ per?: string; + + /** + * Feature mapping. + */ + fpm?: string; }; + + org_permissions?: never; + org_id?: never; + org_slug?: never; + org_role?: never; }; export type JwtPayload = JWTPayloadBase & CustomJwtSessionClaims & VersionedJwtPayload; From 79c00e163e2b12fa3b218bd6522a30bc05b0f224 Mon Sep 17 00:00:00 2001 From: Vaggelis Yfantis Date: Tue, 8 Apr 2025 16:39:57 +0300 Subject: [PATCH 11/20] chore(shared,types): Move SharedSignedInAuthObjectProperties to @clerk/types --- .../src/__tests__/jwtPayloadParser.test.ts | 10 ++++--- packages/shared/src/jwtPayloadParser.ts | 26 ++----------------- packages/types/src/authObject.ts | 23 ++++++++++++++++ packages/types/src/index.ts | 1 + 4 files changed, 32 insertions(+), 28 deletions(-) create mode 100644 packages/types/src/authObject.ts diff --git a/packages/shared/src/__tests__/jwtPayloadParser.test.ts b/packages/shared/src/__tests__/jwtPayloadParser.test.ts index 30820411f1b..e0b4fa996d5 100644 --- a/packages/shared/src/__tests__/jwtPayloadParser.test.ts +++ b/packages/shared/src/__tests__/jwtPayloadParser.test.ts @@ -11,16 +11,18 @@ const baseClaims = { __raw: '', }; -describe('resolveSignedInAuthStateFromJWTClaims', () => { - test.skip('produced auth object is the same for v1 and v2', () => { +describe('JWTPayloadToAuthObjectProperties', () => { + 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_id', rol: 'admin', slg: 'org_slug', - per: 'permission1,permission2', + per: 'read,manage', + fpm: '3', }, }); @@ -29,7 +31,7 @@ describe('resolveSignedInAuthStateFromJWTClaims', () => { org_id: 'org_id', org_role: 'admin', org_slug: 'org_slug', - org_permissions: ['org:permission1', 'permission2'], + org_permissions: ['org:impersonation:read', 'org:impersonation:manage'], }); expect(signedInAuthObjectV1).toEqual(signedInAuthObjectV2); }); diff --git a/packages/shared/src/jwtPayloadParser.ts b/packages/shared/src/jwtPayloadParser.ts index 60aad00aaba..61b735cabe0 100644 --- a/packages/shared/src/jwtPayloadParser.ts +++ b/packages/shared/src/jwtPayloadParser.ts @@ -1,32 +1,10 @@ import type { - ActClaim, JwtPayload, OrganizationCustomPermissionKey, OrganizationCustomRoleKey, - SessionStatusClaim, + SharedSignedInAuthObjectProperties, } from '@clerk/types'; -/** - * @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; -}; - const parseFeatures = (fea: string | undefined) => { const features = fea ? fea.split(',').map(f => f.trim()) : []; @@ -100,7 +78,7 @@ function buildOrgPermissions({ * * Resolves the signed-in auth state from JWT claims. */ -const __experimental_JWTPayloadToAuthObjectProperties = (claims: JwtPayload): SignedInAuthObjectProperties => { +const __experimental_JWTPayloadToAuthObjectProperties = (claims: JwtPayload): SharedSignedInAuthObjectProperties => { let orgId: string | undefined; let orgRole: OrganizationCustomRoleKey | undefined; let orgSlug: string | undefined; diff --git a/packages/types/src/authObject.ts b/packages/types/src/authObject.ts new file mode 100644 index 00000000000..bb617326268 --- /dev/null +++ b/packages/types/src/authObject.ts @@ -0,0 +1,23 @@ +import type { ActClaim, JwtPayload, SessionStatusClaim } from './jwtv2'; +import type { OrganizationCustomPermissionKey, OrganizationCustomRoleKey } from './organizationMembership'; + +/** + * @internal + */ +export type SharedSignedInAuthObjectProperties = { + 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; +}; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 648cc9d7a96..5f2bb42133b 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -67,3 +67,4 @@ export * from './customMenuItems'; export * from './samlConnection'; export * from './waitlist'; export * from './snapshots'; +export * from './authObject'; From 7cc576a94525011388f66da4e05aa9c18bcec0ca Mon Sep 17 00:00:00 2001 From: Vaggelis Yfantis Date: Tue, 8 Apr 2025 20:10:43 +0300 Subject: [PATCH 12/20] fix(shared): Add org: prefix to org role --- packages/shared/src/jwtPayloadParser.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/jwtPayloadParser.ts b/packages/shared/src/jwtPayloadParser.ts index 61b735cabe0..93bae133016 100644 --- a/packages/shared/src/jwtPayloadParser.ts +++ b/packages/shared/src/jwtPayloadParser.ts @@ -93,7 +93,7 @@ const __experimental_JWTPayloadToAuthObjectProperties = (claims: JwtPayload): Sh switch (claims.v) { case 2: { orgId = claims.o?.id; - orgRole = claims.o?.rol; + orgRole = `org:${claims.o?.rol}`; orgSlug = claims.o?.slg; const { orgFeatures } = parseFeatures(claims.fea); From ceab9e7594967b992560a1cc1f18885ce0f37882 Mon Sep 17 00:00:00 2001 From: Vaggelis Yfantis Date: Wed, 9 Apr 2025 12:00:13 +0300 Subject: [PATCH 13/20] fix(shared): Add org: prefix to org roles to match previous behavior --- .../shared/src/__tests__/jwtPayloadParser.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/__tests__/jwtPayloadParser.test.ts b/packages/shared/src/__tests__/jwtPayloadParser.test.ts index e0b4fa996d5..6d6097b1bb3 100644 --- a/packages/shared/src/__tests__/jwtPayloadParser.test.ts +++ b/packages/shared/src/__tests__/jwtPayloadParser.test.ts @@ -18,9 +18,9 @@ describe('JWTPayloadToAuthObjectProperties', () => { v: 2, fea: 'o:impersonation', o: { - id: 'org_id', + id: 'org_xxxxxxx', rol: 'admin', - slg: 'org_slug', + slg: '/test', per: 'read,manage', fpm: '3', }, @@ -28,9 +28,9 @@ describe('JWTPayloadToAuthObjectProperties', () => { const { sessionClaims: v1Claims, ...signedInAuthObjectV1 } = JWTPayloadToAuthObjectProperties({ ...baseClaims, - org_id: 'org_id', - org_role: 'admin', - org_slug: 'org_slug', + org_id: 'org_xxxxxxx', + org_role: 'org:admin', + org_slug: '/test', org_permissions: ['org:impersonation:read', 'org:impersonation:manage'], }); expect(signedInAuthObjectV1).toEqual(signedInAuthObjectV2); @@ -42,9 +42,9 @@ describe('JWTPayloadToAuthObjectProperties', () => { v: 2, fea: 'o:impersonation,o:memberships', o: { - id: 'org_id', + id: 'org_xxxxxxx', rol: 'admin', - slg: 'org_slug', + slg: '/test', per: 'read,manage', fpm: '2,3', }, From 9e968f8d421baee8180dff4a066a40617a85820f Mon Sep 17 00:00:00 2001 From: Vaggelis Yfantis Date: Wed, 9 Apr 2025 12:06:14 +0300 Subject: [PATCH 14/20] chore(clerk-js): Change bundlewatch limit --- 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 5aaea71c0b2..08e80640682 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -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" }, From 1b59290f776aec642812a8f4d72fdfe5034b73ea Mon Sep 17 00:00:00 2001 From: Vaggelis Yfantis Date: Wed, 9 Apr 2025 12:19:28 +0300 Subject: [PATCH 15/20] fix(shared): Only parse permissions if o.per and o.fpm are not undefined --- packages/shared/src/jwtPayloadParser.ts | 36 +++++++++++++++---------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/shared/src/jwtPayloadParser.ts b/packages/shared/src/jwtPayloadParser.ts index 93bae133016..c820e4e1adf 100644 --- a/packages/shared/src/jwtPayloadParser.ts +++ b/packages/shared/src/jwtPayloadParser.ts @@ -16,19 +16,23 @@ const parseFeatures = (fea: string | undefined) => { }; const parsePermissions = ({ per, fpm }: { per?: string; fpm?: string }) => { + if (!per && !fpm) { + return { permissions: [], featurePermissionMap: [] }; + } + const permissions = per ? per.split(',').map(p => p.trim()) : []; // TODO: make this more efficient const featurePermissionMap = fpm ? fpm .split(',') - .map(permission => parseInt(permission.trim(), 10)) + .map(permission => Number.parseInt(permission.trim(), 10)) .map((permission: number) => permission .toString(2) .padStart(permissions.length, '0') .split('') - .map(bit => parseInt(bit, 10)), + .map(bit => Number.parseInt(bit, 10)), ) .filter(Boolean) : []; @@ -93,20 +97,24 @@ const __experimental_JWTPayloadToAuthObjectProperties = (claims: JwtPayload): Sh switch (claims.v) { case 2: { orgId = claims.o?.id; - orgRole = `org:${claims.o?.rol}`; orgSlug = claims.o?.slg; - const { orgFeatures } = parseFeatures(claims.fea); - const { permissions, featurePermissionMap } = parsePermissions({ - per: claims.o?.per ?? '', - fpm: claims.o?.fpm, - }); - - orgPermissions = buildOrgPermissions({ - features: orgFeatures, - featurePermissionMap: featurePermissionMap, - permissions: permissions, - }); + if (claims.o?.rol) { + orgRole = `org:${claims.o?.rol}`; + } + + if (claims.o?.per && claims.o?.fpm) { + const { orgFeatures } = parseFeatures(claims.fea); + const { permissions, featurePermissionMap } = parsePermissions({ + per: claims.o?.per, + fpm: claims.o?.fpm, + }); + orgPermissions = buildOrgPermissions({ + features: orgFeatures, + featurePermissionMap: featurePermissionMap, + permissions: permissions, + }); + } break; } default: From 1937de15897c094f2a770a7bf8e3d03db3646619 Mon Sep 17 00:00:00 2001 From: Vaggelis Yfantis Date: Wed, 9 Apr 2025 12:41:20 +0300 Subject: [PATCH 16/20] fix(shared): Only handle org claims if o is present --- .../src/__tests__/jwtPayloadParser.test.ts | 60 ++++++++++++++++++- packages/shared/src/jwtPayloadParser.ts | 39 ++++++------ 2 files changed, 76 insertions(+), 23 deletions(-) diff --git a/packages/shared/src/__tests__/jwtPayloadParser.test.ts b/packages/shared/src/__tests__/jwtPayloadParser.test.ts index 6d6097b1bb3..fbbc732cb88 100644 --- a/packages/shared/src/__tests__/jwtPayloadParser.test.ts +++ b/packages/shared/src/__tests__/jwtPayloadParser.test.ts @@ -12,6 +12,47 @@ const baseClaims = { }; 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, @@ -93,7 +134,22 @@ describe('JWTPayloadToAuthObjectProperties', () => { ); }); - test('feature are user scoped only', () => { + 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, @@ -105,6 +161,6 @@ describe('JWTPayloadToAuthObjectProperties', () => { }, }); - expect(signedInAuthObject.orgPermissions?.sort()).toEqual([]); + expect(signedInAuthObject.orgRole).toBe('org:admin'); }); }); diff --git a/packages/shared/src/jwtPayloadParser.ts b/packages/shared/src/jwtPayloadParser.ts index c820e4e1adf..c151e439b27 100644 --- a/packages/shared/src/jwtPayloadParser.ts +++ b/packages/shared/src/jwtPayloadParser.ts @@ -16,26 +16,24 @@ const parseFeatures = (fea: string | undefined) => { }; const parsePermissions = ({ per, fpm }: { per?: string; fpm?: string }) => { - if (!per && !fpm) { + if (!per || !fpm) { return { permissions: [], featurePermissionMap: [] }; } - const permissions = per ? per.split(',').map(p => p.trim()) : []; + const permissions = per.split(',').map(p => p.trim()); // TODO: make this more efficient const featurePermissionMap = fpm - ? fpm - .split(',') - .map(permission => Number.parseInt(permission.trim(), 10)) - .map((permission: number) => - permission - .toString(2) - .padStart(permissions.length, '0') - .split('') - .map(bit => Number.parseInt(bit, 10)), - ) - .filter(Boolean) - : []; + .split(',') + .map(permission => Number.parseInt(permission.trim(), 10)) + .map((permission: number) => + permission + .toString(2) + .padStart(permissions.length, '0') + .split('') + .map(bit => Number.parseInt(bit, 10)), + ) + .filter(Boolean); return { permissions, featurePermissionMap }; }; @@ -96,14 +94,13 @@ const __experimental_JWTPayloadToAuthObjectProperties = (claims: JwtPayload): Sh switch (claims.v) { case 2: { - orgId = claims.o?.id; - orgSlug = claims.o?.slg; + if (claims.o) { + orgId = claims.o?.id; + orgSlug = claims.o?.slg; - if (claims.o?.rol) { - orgRole = `org:${claims.o?.rol}`; - } - - if (claims.o?.per && claims.o?.fpm) { + if (claims.o?.rol) { + orgRole = `org:${claims.o?.rol}`; + } const { orgFeatures } = parseFeatures(claims.fea); const { permissions, featurePermissionMap } = parsePermissions({ per: claims.o?.per, From cd352aabd4539ff74f13d91f42669a2455f6aa90 Mon Sep 17 00:00:00 2001 From: Vaggelis Yfantis Date: Wed, 9 Apr 2025 13:36:46 +0300 Subject: [PATCH 17/20] chore(backend): Use SharedSignedInAuthObjectProperties for SignedInAuthObject --- packages/backend/src/tokens/authObjects.ts | 28 ++-------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index a6a5ad23c3b..f674c3b9b44 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -1,14 +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'; @@ -28,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; From f982c48c83461cf1da1040af43ff81277f9af1e8 Mon Sep 17 00:00:00 2001 From: Vaggelis Yfantis Date: Wed, 9 Apr 2025 14:02:07 +0300 Subject: [PATCH 18/20] chore(repo): Update changeset --- .changeset/cool-socks-invite.md | 2 +- .changeset/cyan-hairs-share.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/cool-socks-invite.md b/.changeset/cool-socks-invite.md index 7bc55b1fa4c..8c00af5a520 100644 --- a/.changeset/cool-socks-invite.md +++ b/.changeset/cool-socks-invite.md @@ -3,4 +3,4 @@ '@clerk/types': minor --- -Added new `org` claim for JWT v2 schema +Adding the new `o` claim that contains all organization related info for JWT v2 schema diff --git a/.changeset/cyan-hairs-share.md b/.changeset/cyan-hairs-share.md index 50330e5c9df..f8331434257 100644 --- a/.changeset/cyan-hairs-share.md +++ b/.changeset/cyan-hairs-share.md @@ -3,4 +3,4 @@ '@clerk/backend': patch --- -Uses the `__experimental_resolveSignedInAuthStateFromJWTClaims` from `@clerk/shared` to handle the new JWT v2 schema +Uses the helper function `__experimental_JWTPayloadToAuthObjectProperties` from `@clerk/shared` to handle the new JWT v2 schema. From 5f2791812963efaf6bd462b4ba9b7b5883d57235 Mon Sep 17 00:00:00 2001 From: Vaggelis Yfantis Date: Wed, 9 Apr 2025 14:15:50 +0300 Subject: [PATCH 19/20] chore(shared,types): Remove v from JWTPayloadBase as is not needed --- packages/shared/src/authorization.ts | 1 - packages/types/src/jwtv2.ts | 12 +++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index eec1d7a05f5..a49da464b3c 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -12,7 +12,6 @@ import type { SignOut, UseAuthReturn, } from '@clerk/types'; -import { permission } from 'process'; type TypesToConfig = Record>; type AuthorizationOptions = { diff --git a/packages/types/src/jwtv2.ts b/packages/types/src/jwtv2.ts index 5a8495bc270..c08f4316b3c 100644 --- a/packages/types/src/jwtv2.ts +++ b/packages/types/src/jwtv2.ts @@ -38,13 +38,6 @@ declare global { } type JWTPayloadBase = { - /** - * @experimental - * - * The version of the JWT payload. - */ - v?: number | undefined; - /** * Encoded token supporting the `getRawString` method. */ @@ -134,6 +127,11 @@ export type VersionedJwtPayload = org_role?: OrganizationCustomRoleKey; } | { + /** + * @experimental + * + * The version of the JWT payload. + */ v: 2; /** From c1cb785241d092bfed74bde8e70b0547dd06bb02 Mon Sep 17 00:00:00 2001 From: Vaggelis Yfantis Date: Wed, 9 Apr 2025 14:19:02 +0300 Subject: [PATCH 20/20] chore(clerk-js): Change bundlewatch limit --- 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 08e80640682..4ea9cf54e4d 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,7 +1,7 @@ { "files": [ { "path": "./dist/clerk.js", "maxSize": "590kB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "75KB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "73.21KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "55KB" }, { "path": "./dist/ui-common*.js", "maxSize": "98.2KB" }, { "path": "./dist/vendors*.js", "maxSize": "36KB" },