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;
};