Skip to content

Commit ed10566

Browse files
authored
feat(shared): Allow for has({ role | permission}) without scope (#5693)
1 parent 29462b4 commit ed10566

File tree

8 files changed

+59
-2
lines changed

8 files changed

+59
-2
lines changed

.changeset/salty-worms-attack.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@clerk/shared': patch
3+
---
4+
5+
Allow for `has({ role | permission})` without scope.
6+
7+
Examples:
8+
- `has({role: "admin"})`
9+
- `has({permission: "friends:add"})`

integration/templates/next-app-router/src/app/jwt-v2-organizations/(tests)/conditionals.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
11
export function Conditionals({
22
hasImpersonationRead,
33
hasMagicLinksCreate,
4+
hasMagicLinksCreateUnscoped,
45
hasMagicLinksRead,
56
hasImpersonationManage,
67
hasAdminRole,
78
hasManagerRole,
89
hasImpersonationReaderRole,
10+
hasImpersonationReaderRoleUnscoped,
911
role,
1012
hasImpersonationFeature,
1113
hasMagicLinksFeature,
1214
}: {
1315
hasImpersonationRead: boolean;
1416
hasMagicLinksCreate: boolean;
17+
hasMagicLinksCreateUnscoped: boolean;
1518
hasMagicLinksRead: boolean;
1619
hasImpersonationManage: boolean;
1720
hasAdminRole: boolean;
1821
hasManagerRole: boolean;
1922
hasImpersonationReaderRole: boolean;
23+
hasImpersonationReaderRoleUnscoped: boolean;
2024
role: string | null | undefined;
2125
hasImpersonationFeature: boolean;
2226
hasMagicLinksFeature: boolean;
@@ -33,6 +37,11 @@ export function Conditionals({
3337
{hasMagicLinksCreate ? 'true' : 'false'}
3438
</pre>
3539

40+
<pre>
41+
{`has({ permission: "magic_links:create" }) -> `}
42+
{hasMagicLinksCreateUnscoped ? 'true' : 'false'}
43+
</pre>
44+
3645
<pre>
3746
{`has({ permission: "org:magic_links:read" }) -> `}
3847
{hasMagicLinksRead ? 'true' : 'false'}
@@ -58,6 +67,11 @@ export function Conditionals({
5867
{hasImpersonationReaderRole ? 'true' : 'false'}
5968
</pre>
6069

70+
<pre>
71+
{`has({ role: "impersonation_reader" }) -> `}
72+
{hasImpersonationReaderRoleUnscoped ? 'true' : 'false'}
73+
</pre>
74+
6175
<pre>
6276
{`role -> `}
6377
{role}

integration/templates/next-app-router/src/app/jwt-v2-organizations/(tests)/has-client/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export default function Page() {
2323
role={orgRole}
2424
hasImpersonationFeature={has({ feature: 'org:impersonation' })}
2525
hasMagicLinksFeature={has({ feature: 'org:magic_links' })}
26+
hasMagicLinksCreateUnscoped={has({ permission: 'magic_links:create' })}
27+
hasImpersonationReaderRoleUnscoped={has({ role: 'impersonation_reader' })}
2628
/>
2729
</>
2830
);

integration/templates/next-app-router/src/app/jwt-v2-organizations/(tests)/has-server/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export default async function Page() {
1818
role={orgRole}
1919
hasImpersonationFeature={has({ feature: 'org:impersonation' })}
2020
hasMagicLinksFeature={has({ feature: 'org:magic_links' })}
21+
hasMagicLinksCreateUnscoped={has({ permission: 'magic_links:create' })}
22+
hasImpersonationReaderRoleUnscoped={has({ role: 'impersonation_reader' })}
2123
/>
2224
</>
2325
);

integration/templates/next-app-router/src/app/jwt-v2-organizations/(tests)/has-ssr/client.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export function SSR() {
2323
role={orgRole}
2424
hasImpersonationFeature={has({ feature: 'org:impersonation' })}
2525
hasMagicLinksFeature={has({ feature: 'org:magic_links' })}
26+
hasMagicLinksCreateUnscoped={has({ permission: 'magic_links:create' })}
27+
hasImpersonationReaderRoleUnscoped={has({ role: 'impersonation_reader' })}
2628
/>
2729
</>
2830
);

integration/tests/protect-jwt-v2.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ testAgainstRunningApps({
7474
async function assertPermsRolesFeatures() {
7575
await expect(u.page.getByText(`has({ permission: "org:impersonation:read" }) -> true`)).toBeVisible();
7676
await expect(u.page.getByText(`has({ permission: "org:magic_links:create" }) -> true`)).toBeVisible();
77+
await expect(u.page.getByText(`has({ permission: "magic_links:create" }) -> true`)).toBeVisible();
7778
await expect(u.page.getByText(`has({ permission: "org:magic_links:read" }) -> true`)).toBeVisible();
7879
await expect(u.page.getByText(`has({ permission: "org:impersonation:manage" }) -> true`)).toBeVisible();
7980
await expect(u.page.getByText(`has({ role: "org:admin" }) -> true`)).toBeVisible();
@@ -171,6 +172,7 @@ testAgainstRunningApps({
171172
await expect(u.page.getByText(`has({ role: "org:admin" }) -> false`)).toBeVisible();
172173
await expect(u.page.getByText(`has({ role: "org:manager" }) -> false`)).toBeVisible();
173174
await expect(u.page.getByText(`has({ role: "org:impersonation_reader" }) -> true`)).toBeVisible();
175+
await expect(u.page.getByText(`has({ role: "impersonation_reader" }) -> true`)).toBeVisible();
174176
await expect(u.page.getByText(`role -> org:impersonation_reader`)).toBeVisible();
175177
await expect(u.page.getByText(`has({ feature: "org:impersonation" }) -> true`)).toBeVisible();
176178
await expect(u.page.getByText(`has({ feature: "org:magic_links" }) -> true`)).toBeVisible();

packages/backend/src/tokens/__tests__/authObjects.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,26 @@ describe('signedInAuthObject', () => {
3434
});
3535

3636
describe('JWT v1', () => {
37+
it('has() for user scope', () => {
38+
const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext;
39+
40+
const partialJwtPayload = {
41+
___raw: 'raw',
42+
act: { sub: 'actor' },
43+
sid: 'sessionId',
44+
sub: 'userId',
45+
} as Partial<JwtPayload>;
46+
47+
const authObject = signedInAuthObject(mockAuthenticateContext, 'token', partialJwtPayload as JwtPayload);
48+
49+
expect(authObject.has({ role: 'org:admin' })).toBe(false);
50+
expect(authObject.has({ role: 'admin' })).toBe(false);
51+
expect(authObject.has({ permission: 'org:f1:read' })).toBe(false);
52+
expect(authObject.has({ permission: 'f1:read' })).toBe(false);
53+
expect(authObject.has({ feature: 'org:reservations' })).toBe(false);
54+
expect(authObject.has({ feature: 'org:impersonation' })).toBe(false);
55+
});
56+
3757
it('has() for orgs', () => {
3858
const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext;
3959

@@ -51,7 +71,9 @@ describe('signedInAuthObject', () => {
5171
const authObject = signedInAuthObject(mockAuthenticateContext, 'token', partialJwtPayload as JwtPayload);
5272

5373
expect(authObject.has({ role: 'org:admin' })).toBe(true);
74+
expect(authObject.has({ role: 'admin' })).toBe(true);
5475
expect(authObject.has({ permission: 'org:f1:read' })).toBe(true);
76+
expect(authObject.has({ permission: 'f1:read' })).toBe(true);
5577
expect(authObject.has({ permission: 'org:f1' })).toBe(false);
5678
expect(authObject.has({ permission: 'org:f2:manage' })).toBe(true);
5779
expect(authObject.has({ permission: 'org:f2' })).toBe(false);
@@ -85,7 +107,9 @@ describe('signedInAuthObject', () => {
85107
const authObject = signedInAuthObject(mockAuthenticateContext, 'token', partialJwtPayload as JwtPayload);
86108

87109
expect(authObject.has({ role: 'org:admin' })).toBe(true);
110+
expect(authObject.has({ role: 'admin' })).toBe(true);
88111
expect(authObject.has({ permission: 'org:reservations:read' })).toBe(true);
112+
expect(authObject.has({ permission: 'reservations:read' })).toBe(true);
89113
expect(authObject.has({ permission: 'org:reservations' })).toBe(false);
90114
expect(authObject.has({ permission: 'org:reservations:manage' })).toBe(true);
91115
expect(authObject.has({ permission: 'org:reservations' })).toBe(false);

packages/shared/src/authorization.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ const isValidMaxAge = (maxAge: any) => typeof maxAge === 'number' && maxAge > 0;
7070
const isValidLevel = (level: any) => ALLOWED_LEVELS.has(level);
7171
const isValidVerificationType = (type: any) => ALLOWED_TYPES.has(type);
7272

73+
const prefixWithOrg = (value: string) => (value.startsWith('org:') ? value : `org:${value}`);
74+
7375
/**
7476
* Checks if a user has the required organization-level authorization.
7577
* Verifies if the user has the specified role or permission within their organization.
@@ -86,11 +88,11 @@ const checkOrgAuthorization: CheckOrgAuthorization = (params, options) => {
8688
}
8789

8890
if (params.permission) {
89-
return orgPermissions.includes(params.permission);
91+
return orgPermissions.includes(prefixWithOrg(params.permission));
9092
}
9193

9294
if (params.role) {
93-
return orgRole === params.role;
95+
return orgRole === prefixWithOrg(params.role);
9496
}
9597
return null;
9698
};

0 commit comments

Comments
 (0)