From 85a50b5d4677e86d386001bf7387c382a8e36eb3 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 22 May 2025 20:13:06 -0400 Subject: [PATCH 1/3] fix(shared,backend): Allow for old org_role format in JWT v1 where `org:` is missing --- .changeset/forty-trains-fail.md | 20 +++++++++++++ .../src/tokens/__tests__/authObjects.test.ts | 28 +++++++++++++++++++ packages/shared/src/authorization.ts | 8 ++++-- 3 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 .changeset/forty-trains-fail.md diff --git a/.changeset/forty-trains-fail.md b/.changeset/forty-trains-fail.md new file mode 100644 index 00000000000..f515944f6e3 --- /dev/null +++ b/.changeset/forty-trains-fail.md @@ -0,0 +1,20 @@ +--- +'@clerk/shared': patch +--- + +Bug fix: In `createCheckAuthorization` allow for old `org_role` format in JWT v1 where `org:` is missing. + +Example session claims: +```json +{ + "org_id": "org_xxxx", + "org_permissions": [], + "org_role": "admin", + "org_slug": "test" +} +``` +Code +```ts +authObject.has({ role: 'org:admin' }) // -> true +authObject.has({ role: 'admin' }) // -> true +``` diff --git a/packages/backend/src/tokens/__tests__/authObjects.test.ts b/packages/backend/src/tokens/__tests__/authObjects.test.ts index 02746b3f143..27b85fd51fc 100644 --- a/packages/backend/src/tokens/__tests__/authObjects.test.ts +++ b/packages/backend/src/tokens/__tests__/authObjects.test.ts @@ -81,6 +81,34 @@ describe('signedInAuthObject', () => { expect(authObject.has({ feature: 'org:reservations' })).toBe(false); expect(authObject.has({ feature: 'org:impersonation' })).toBe(false); }); + + it('has() for orgs for old `admin` role', () => { + const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext; + + const partialJwtPayload = { + ___raw: 'raw', + act: { sub: 'actor' }, + sid: 'sessionId', + org_id: 'orgId', + org_role: 'admin', + org_slug: 'orgSlug', + org_permissions: ['org:f1:read', 'org:f2:manage'], + sub: 'userId', + } as Partial; + + const authObject = signedInAuthObject(mockAuthenticateContext, 'token', partialJwtPayload as JwtPayload); + + expect(authObject.has({ role: 'org:admin' })).toBe(true); + expect(authObject.has({ role: 'admin' })).toBe(true); + expect(authObject.has({ permission: 'org:f1:read' })).toBe(true); + expect(authObject.has({ permission: 'f1:read' })).toBe(true); + expect(authObject.has({ permission: 'org:f1' })).toBe(false); + expect(authObject.has({ permission: 'org:f2:manage' })).toBe(true); + expect(authObject.has({ permission: 'org:f2' })).toBe(false); + + expect(authObject.has({ feature: 'org:reservations' })).toBe(false); + expect(authObject.has({ feature: 'org:impersonation' })).toBe(false); + }); }); describe('JWT v2', () => { diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index e8f3bee080e..9ad14da5677 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -92,7 +92,7 @@ const checkOrgAuthorization: CheckOrgAuthorization = (params, options) => { } if (params.role) { - return orgRole === prefixWithOrg(params.role); + return prefixWithOrg(orgRole) === prefixWithOrg(params.role); } return null; }; @@ -162,7 +162,8 @@ const validateReverificationConfig = (config: ReverificationConfig | undefined | * Evaluates if the user meets re-verification authentication requirements. * Compares the user's factor verification ages against the specified maxAge. * Handles different verification levels (first factor, second factor, multi-factor). - * @returns null, if requirements or verification data are missing. + * + * @returns Null, if requirements or verification data are missing. */ const checkReverificationAuthorization: CheckReverificationAuthorization = (params, { factorVerificationAge }) => { if (!params.reverification || !factorVerificationAge) { @@ -236,7 +237,8 @@ type AuthStateOptions = { /** * Shared utility function that centralizes auth state resolution logic, - * preventing duplication across different packages + * preventing duplication across different packages. + * * @internal */ const resolveAuthState = ({ From b1c8f18438287759e0ad5fdfd8cccdb5697ba02c Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 23 May 2025 02:11:00 -0400 Subject: [PATCH 2/3] handle one more case --- .../src/tokens/__tests__/authObjects.test.ts | 27 +++++++++++++++++++ packages/shared/src/authorization.ts | 5 ++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/tokens/__tests__/authObjects.test.ts b/packages/backend/src/tokens/__tests__/authObjects.test.ts index 27b85fd51fc..f79f9b17ddb 100644 --- a/packages/backend/src/tokens/__tests__/authObjects.test.ts +++ b/packages/backend/src/tokens/__tests__/authObjects.test.ts @@ -148,6 +148,33 @@ describe('signedInAuthObject', () => { expect(authObject.has({ feature: 'org:impersonation' })).toBe(true); }); + // This state should not happen since the JWT v2 payload is normalized to remove the `org:` prefix from o.rol. + it('has() for orgs with `org:` prefix in role', () => { + const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext; + + const partialJwtPayload = { + v: 2, + ___raw: 'raw', + act: { sub: 'actor' }, + sid: 'sessionId', + fea: 'o:reservations,o:impersonation', + o: { + id: 'orgId', + rol: 'org:admin', + slg: 'orgSlug', + per: 'read,manage', + fpm: '3', + }, + + sub: 'userId', + } as Partial; + + const authObject = signedInAuthObject(mockAuthenticateContext, 'token', partialJwtPayload as JwtPayload); + + expect(authObject.has({ role: 'org:admin' })).toBe(true); + expect(authObject.has({ role: 'admin' })).toBe(true); + }); + it('has() for billing with scopes', () => { const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext; diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index 9ad14da5677..e0dbfab9a17 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -70,12 +70,13 @@ const isValidMaxAge = (maxAge: any) => typeof maxAge === 'number' && maxAge > 0; const isValidLevel = (level: any) => ALLOWED_LEVELS.has(level); const isValidVerificationType = (type: any) => ALLOWED_TYPES.has(type); -const prefixWithOrg = (value: string) => (value.startsWith('org:') ? value : `org:${value}`); +const prefixWithOrg = (value: string) => value.replace(/^(org:)*/, 'org:'); /** * Checks if a user has the required organization-level authorization. * Verifies if the user has the specified role or permission within their organization. - * @returns null, if unable to determine due to missing data or unspecified role/permission. + * + * @returns Null, if unable to determine due to missing data or unspecified role/permission. */ const checkOrgAuthorization: CheckOrgAuthorization = (params, options) => { const { orgId, orgRole, orgPermissions } = options; From 8e4ec7e44a13217071767c6a930b5f8b65822c99 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 23 May 2025 02:11:25 -0400 Subject: [PATCH 3/3] revert change --- packages/shared/src/authorization.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index e0dbfab9a17..cb8c7e04428 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -75,8 +75,7 @@ const prefixWithOrg = (value: string) => value.replace(/^(org:)*/, 'org:'); /** * Checks if a user has the required organization-level authorization. * Verifies if the user has the specified role or permission within their organization. - * - * @returns Null, if unable to determine due to missing data or unspecified role/permission. + * @returns null, if unable to determine due to missing data or unspecified role/permission. */ const checkOrgAuthorization: CheckOrgAuthorization = (params, options) => { const { orgId, orgRole, orgPermissions } = options; @@ -163,8 +162,7 @@ const validateReverificationConfig = (config: ReverificationConfig | undefined | * Evaluates if the user meets re-verification authentication requirements. * Compares the user's factor verification ages against the specified maxAge. * Handles different verification levels (first factor, second factor, multi-factor). - * - * @returns Null, if requirements or verification data are missing. + * @returns null, if requirements or verification data are missing. */ const checkReverificationAuthorization: CheckReverificationAuthorization = (params, { factorVerificationAge }) => { if (!params.reverification || !factorVerificationAge) { @@ -239,7 +237,6 @@ type AuthStateOptions = { /** * Shared utility function that centralizes auth state resolution logic, * preventing duplication across different packages. - * * @internal */ const resolveAuthState = ({