diff --git a/.changeset/cool-socks-invite.md b/.changeset/cool-socks-invite.md new file mode 100644 index 00000000000..8c00af5a520 --- /dev/null +++ b/.changeset/cool-socks-invite.md @@ -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 diff --git a/.changeset/cyan-hairs-share.md b/.changeset/cyan-hairs-share.md new file mode 100644 index 00000000000..f8331434257 --- /dev/null +++ b/.changeset/cyan-hairs-share.md @@ -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. diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index 8f652eadfd3..f674c3b9b44 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -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'; @@ -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; @@ -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 */ @@ -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, diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 5aaea71c0b2..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": "72.7KB" }, + { "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" }, diff --git a/packages/clerk-js/src/core/jwt-client.ts b/packages/clerk-js/src/core/jwt-client.ts index 2459617e2b2..7de4e84f84f 100644 --- a/packages/clerk-js/src/core/jwt-client.ts +++ b/packages/clerk-js/src/core/jwt-client.ts @@ -1,3 +1,4 @@ +import { __experimental_JWTPayloadToAuthObjectProperties } from '@clerk/shared/jwtPayloadParser'; 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 } = + __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, }, 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__/jwtPayloadParser.test.ts b/packages/shared/src/__tests__/jwtPayloadParser.test.ts new file mode 100644 index 00000000000..fbbc732cb88 --- /dev/null +++ b/packages/shared/src/__tests__/jwtPayloadParser.test.ts @@ -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'); + }); +}); diff --git a/packages/shared/src/jwtPayloadParser.ts b/packages/shared/src/jwtPayloadParser.ts new file mode 100644 index 00000000000..c151e439b27 --- /dev/null +++ b/packages/shared/src/jwtPayloadParser.ts @@ -0,0 +1,139 @@ +import type { + JwtPayload, + OrganizationCustomPermissionKey, + OrganizationCustomRoleKey, + SharedSignedInAuthObjectProperties, +} from '@clerk/types'; + +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 }) => { + if (!per || !fpm) { + return { permissions: [], featurePermissionMap: [] }; + } + + const permissions = per.split(',').map(p => p.trim()); + + // TODO: make this more efficient + const featurePermissionMap = 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); + + 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): SharedSignedInAuthObjectProperties => { + 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: { + if (claims.o) { + orgId = claims.o?.id; + orgSlug = claims.o?.slg; + + if (claims.o?.rol) { + orgRole = `org:${claims.o?.rol}`; + } + 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/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'; diff --git a/packages/types/src/jwtv2.ts b/packages/types/src/jwtv2.ts index 65e5ca59bb1..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. - */ - ver: number | undefined; - /** * Encoded token supporting the `getRawString` method. */ @@ -97,21 +90,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 */ @@ -125,18 +103,28 @@ type JWTPayloadBase = { export type VersionedJwtPayload = | { - /** - * @experimental - * - * The version of the JWT payload. - */ - ver?: never; + v?: undefined; /** * * Active organization permissions. */ org_permissions?: OrganizationCustomPermissionKey[]; + + /** + * Active organization ID. + */ + org_id?: string; + + /** + * Active organization slug. + */ + org_slug?: string; + + /** + * Active organization role. + */ + org_role?: OrganizationCustomRoleKey; } | { /** @@ -144,10 +132,49 @@ export type VersionedJwtPayload = * * The version of the JWT payload. */ - ver: 2; + v: 2; + + /** + * Features for session. + */ + fea?: string; + + /** + * @experimental - This structure is subject to change. + * + * Active organization information. + */ + o?: { + /** + * Active organization ID. + */ + id: string; + + /** + * Active organization slug. + */ + slg?: string; + + /** + * Active organization role. + */ + rol?: OrganizationCustomRoleKey; + + /** + * Active organization permissions. + */ + per?: string; + + /** + * Feature mapping. + */ + fpm?: string; + }; org_permissions?: never; - // TODO: include the version 2 claims here + org_id?: never; + org_slug?: never; + org_role?: never; }; export type JwtPayload = JWTPayloadBase & CustomJwtSessionClaims & VersionedJwtPayload;