diff --git a/.changeset/salty-worms-attack.md b/.changeset/salty-worms-attack.md new file mode 100644 index 00000000000..2342021c818 --- /dev/null +++ b/.changeset/salty-worms-attack.md @@ -0,0 +1,9 @@ +--- +'@clerk/shared': patch +--- + +Allow for `has({ role | permission})` without scope. + +Examples: +- `has({role: "admin"})` +- `has({permission: "friends:add"})` diff --git a/integration/templates/next-app-router/src/app/jwt-v2-organizations/(tests)/conditionals.tsx b/integration/templates/next-app-router/src/app/jwt-v2-organizations/(tests)/conditionals.tsx index 3ed14d4c064..3aa88f76f15 100644 --- a/integration/templates/next-app-router/src/app/jwt-v2-organizations/(tests)/conditionals.tsx +++ b/integration/templates/next-app-router/src/app/jwt-v2-organizations/(tests)/conditionals.tsx @@ -1,22 +1,26 @@ export function Conditionals({ hasImpersonationRead, hasMagicLinksCreate, + hasMagicLinksCreateUnscoped, hasMagicLinksRead, hasImpersonationManage, hasAdminRole, hasManagerRole, hasImpersonationReaderRole, + hasImpersonationReaderRoleUnscoped, role, hasImpersonationFeature, hasMagicLinksFeature, }: { hasImpersonationRead: boolean; hasMagicLinksCreate: boolean; + hasMagicLinksCreateUnscoped: boolean; hasMagicLinksRead: boolean; hasImpersonationManage: boolean; hasAdminRole: boolean; hasManagerRole: boolean; hasImpersonationReaderRole: boolean; + hasImpersonationReaderRoleUnscoped: boolean; role: string | null | undefined; hasImpersonationFeature: boolean; hasMagicLinksFeature: boolean; @@ -33,6 +37,11 @@ export function Conditionals({ {hasMagicLinksCreate ? 'true' : 'false'} +
+        {`has({ permission: "magic_links:create" }) -> `}
+        {hasMagicLinksCreateUnscoped ? 'true' : 'false'}
+      
+
         {`has({ permission: "org:magic_links:read" }) -> `}
         {hasMagicLinksRead ? 'true' : 'false'}
@@ -58,6 +67,11 @@ export function Conditionals({
         {hasImpersonationReaderRole ? 'true' : 'false'}
       
+
+        {`has({ role: "impersonation_reader" }) -> `}
+        {hasImpersonationReaderRoleUnscoped ? 'true' : 'false'}
+      
+
         {`role -> `}
         {role}
diff --git a/integration/templates/next-app-router/src/app/jwt-v2-organizations/(tests)/has-client/page.tsx b/integration/templates/next-app-router/src/app/jwt-v2-organizations/(tests)/has-client/page.tsx
index f41b0a07b8f..066f22b3301 100644
--- a/integration/templates/next-app-router/src/app/jwt-v2-organizations/(tests)/has-client/page.tsx
+++ b/integration/templates/next-app-router/src/app/jwt-v2-organizations/(tests)/has-client/page.tsx
@@ -23,6 +23,8 @@ export default function Page() {
         role={orgRole}
         hasImpersonationFeature={has({ feature: 'org:impersonation' })}
         hasMagicLinksFeature={has({ feature: 'org:magic_links' })}
+        hasMagicLinksCreateUnscoped={has({ permission: 'magic_links:create' })}
+        hasImpersonationReaderRoleUnscoped={has({ role: 'impersonation_reader' })}
       />
     
   );
diff --git a/integration/templates/next-app-router/src/app/jwt-v2-organizations/(tests)/has-server/page.tsx b/integration/templates/next-app-router/src/app/jwt-v2-organizations/(tests)/has-server/page.tsx
index 0f3ef77919f..9ba67c4a2c0 100644
--- a/integration/templates/next-app-router/src/app/jwt-v2-organizations/(tests)/has-server/page.tsx
+++ b/integration/templates/next-app-router/src/app/jwt-v2-organizations/(tests)/has-server/page.tsx
@@ -18,6 +18,8 @@ export default async function Page() {
         role={orgRole}
         hasImpersonationFeature={has({ feature: 'org:impersonation' })}
         hasMagicLinksFeature={has({ feature: 'org:magic_links' })}
+        hasMagicLinksCreateUnscoped={has({ permission: 'magic_links:create' })}
+        hasImpersonationReaderRoleUnscoped={has({ role: 'impersonation_reader' })}
       />
     
   );
diff --git a/integration/templates/next-app-router/src/app/jwt-v2-organizations/(tests)/has-ssr/client.tsx b/integration/templates/next-app-router/src/app/jwt-v2-organizations/(tests)/has-ssr/client.tsx
index 8e23bd36827..56dc71c3187 100644
--- a/integration/templates/next-app-router/src/app/jwt-v2-organizations/(tests)/has-ssr/client.tsx
+++ b/integration/templates/next-app-router/src/app/jwt-v2-organizations/(tests)/has-ssr/client.tsx
@@ -23,6 +23,8 @@ export function SSR() {
         role={orgRole}
         hasImpersonationFeature={has({ feature: 'org:impersonation' })}
         hasMagicLinksFeature={has({ feature: 'org:magic_links' })}
+        hasMagicLinksCreateUnscoped={has({ permission: 'magic_links:create' })}
+        hasImpersonationReaderRoleUnscoped={has({ role: 'impersonation_reader' })}
       />
     
   );
diff --git a/integration/tests/protect-jwt-v2.test.ts b/integration/tests/protect-jwt-v2.test.ts
index 303590921dd..545067e086e 100644
--- a/integration/tests/protect-jwt-v2.test.ts
+++ b/integration/tests/protect-jwt-v2.test.ts
@@ -74,6 +74,7 @@ testAgainstRunningApps({
     async function assertPermsRolesFeatures() {
       await expect(u.page.getByText(`has({ permission: "org:impersonation:read" }) -> true`)).toBeVisible();
       await expect(u.page.getByText(`has({ permission: "org:magic_links:create" }) -> true`)).toBeVisible();
+      await expect(u.page.getByText(`has({ permission: "magic_links:create" }) -> true`)).toBeVisible();
       await expect(u.page.getByText(`has({ permission: "org:magic_links:read" }) -> true`)).toBeVisible();
       await expect(u.page.getByText(`has({ permission: "org:impersonation:manage" }) -> true`)).toBeVisible();
       await expect(u.page.getByText(`has({ role: "org:admin" }) -> true`)).toBeVisible();
@@ -171,6 +172,7 @@ testAgainstRunningApps({
       await expect(u.page.getByText(`has({ role: "org:admin" }) -> false`)).toBeVisible();
       await expect(u.page.getByText(`has({ role: "org:manager" }) -> false`)).toBeVisible();
       await expect(u.page.getByText(`has({ role: "org:impersonation_reader" }) -> true`)).toBeVisible();
+      await expect(u.page.getByText(`has({ role: "impersonation_reader" }) -> true`)).toBeVisible();
       await expect(u.page.getByText(`role -> org:impersonation_reader`)).toBeVisible();
       await expect(u.page.getByText(`has({ feature: "org:impersonation" }) -> true`)).toBeVisible();
       await expect(u.page.getByText(`has({ feature: "org:magic_links" }) -> true`)).toBeVisible();
diff --git a/packages/backend/src/tokens/__tests__/authObjects.test.ts b/packages/backend/src/tokens/__tests__/authObjects.test.ts
index 0cc554de98e..52e69cdf34f 100644
--- a/packages/backend/src/tokens/__tests__/authObjects.test.ts
+++ b/packages/backend/src/tokens/__tests__/authObjects.test.ts
@@ -33,6 +33,26 @@ describe('signedInAuthObject', () => {
   });
 
   describe('JWT v1', () => {
+    it('has() for user scope', () => {
+      const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext;
+
+      const partialJwtPayload = {
+        ___raw: 'raw',
+        act: { sub: 'actor' },
+        sid: 'sessionId',
+        sub: 'userId',
+      } as Partial;
+
+      const authObject = signedInAuthObject(mockAuthenticateContext, 'token', partialJwtPayload as JwtPayload);
+
+      expect(authObject.has({ role: 'org:admin' })).toBe(false);
+      expect(authObject.has({ role: 'admin' })).toBe(false);
+      expect(authObject.has({ permission: 'org:f1:read' })).toBe(false);
+      expect(authObject.has({ permission: 'f1:read' })).toBe(false);
+      expect(authObject.has({ feature: 'org:reservations' })).toBe(false);
+      expect(authObject.has({ feature: 'org:impersonation' })).toBe(false);
+    });
+
     it('has() for orgs', () => {
       const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext;
 
@@ -50,7 +70,9 @@ describe('signedInAuthObject', () => {
       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);
@@ -84,7 +106,9 @@ describe('signedInAuthObject', () => {
       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:reservations:read' })).toBe(true);
+      expect(authObject.has({ permission: 'reservations:read' })).toBe(true);
       expect(authObject.has({ permission: 'org:reservations' })).toBe(false);
       expect(authObject.has({ permission: 'org:reservations:manage' })).toBe(true);
       expect(authObject.has({ permission: 'org:reservations' })).toBe(false);
diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts
index 716ba70a012..e8f3bee080e 100644
--- a/packages/shared/src/authorization.ts
+++ b/packages/shared/src/authorization.ts
@@ -70,6 +70,8 @@ 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}`);
+
 /**
  * Checks if a user has the required organization-level authorization.
  * Verifies if the user has the specified role or permission within their organization.
@@ -86,11 +88,11 @@ const checkOrgAuthorization: CheckOrgAuthorization = (params, options) => {
   }
 
   if (params.permission) {
-    return orgPermissions.includes(params.permission);
+    return orgPermissions.includes(prefixWithOrg(params.permission));
   }
 
   if (params.role) {
-    return orgRole === params.role;
+    return orgRole === prefixWithOrg(params.role);
   }
   return null;
 };