Skip to content

Commit 431a821

Browse files
authored
feat(clerk-react,backend,shared,types): Introduce has({ feature | plan }) (#5582)
1 parent ba6d2ee commit 431a821

21 files changed

Lines changed: 485 additions & 43 deletions

File tree

.changeset/angry-nights-smash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/backend': minor
3+
---
4+
5+
Add support for feature or plan based authorization.

.changeset/cold-bears-go.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/types': minor
3+
---
4+
5+
Add `pla` claim to `VersionedJwtPayload`.

.changeset/every-feet-brush.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/shared': minor
3+
---
4+
5+
Replace `parseFeatures` with `splitByScope`.

.changeset/flat-cougars-mate.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/shared': minor
3+
---
4+
5+
Update `createCheckAuthorization` to support authorization based on features and plans.

.changeset/four-doors-attack.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@clerk/clerk-react': minor
3+
---
4+
5+
Add support for feature or plan based authorization
6+
7+
## `useAuth()`
8+
### Plan
9+
- `useAuth().has({ plan: "my-plan" })`
10+
11+
### Feature
12+
- `useAuth().has({ feature: "my-feature" })`
13+
14+
### Scoped per user or per org
15+
- `useAuth().has({ feature: "org:my-feature" })`
16+
- `useAuth().has({ feature: "user:my-feature" })`
17+
- `useAuth().has({ plan: "user:my-plan" })`
18+
- `useAuth().has({ plan: "org:my-plan" })`
19+
20+
## `<Protect />`
21+
22+
### Plan
23+
- `<Protect plan="my-plan" />`
24+
25+
### Feature
26+
- `<Protect feature="my-feature" />`
27+
28+
### Scoped per user or per org
29+
- `<Protect feature="org:my-feature" />`
30+
- `<Protect feature="user:my-feature" />`
31+
- `<Protect plan="org:my-plan" />`
32+
- `<Protect plan="user:my-plan" />`
33+

.changeset/four-streets-join.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
---
4+
5+
Add support for feature or plan based authorization
6+
7+
### Plan
8+
- `Clerk.session.checkAuthorization({ plan: "my-plan" })`
9+
10+
### Feature
11+
- `Clerk.session.checkAuthorization({ feature: "my-feature" })`
12+
13+
### Scoped per user or per org
14+
- `Clerk.session.checkAuthorization({ feature: "org:my-feature" })`
15+
- `Clerk.session.checkAuthorization({ feature: "user:my-feature" })`
16+
- `Clerk.session.checkAuthorization({ plan: "user:my-plan" })`
17+
- `Clerk.session.checkAuthorization({ plan: "org:my-plan" })`

.changeset/hip-sides-kick.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
'@clerk/nextjs': minor
3+
---
4+
5+
Add support for feature or plan based authorization
6+
7+
8+
## `await auth()`
9+
### Plan
10+
- `(await auth()).has({ plan: "my-plan" })`
11+
12+
### Feature
13+
- `(await auth()).has({ feature: "my-feature" })`
14+
15+
### Scoped per user or per org
16+
- `(await auth()).has({ feature: "org:my-feature" })`
17+
- `(await auth()).has({ feature: "user:my-feature" })`
18+
- `(await auth()).has({ plan: "user:my-plan" })`
19+
- `(await auth()).has({ plan: "org:my-plan" })`
20+
21+
## `auth.protect()`
22+
### Plan
23+
- `auth.protect({ plan: "my-plan" })`
24+
25+
### Feature
26+
- `auth.protect({ feature: "my-feature" })`
27+
28+
### Scoped per user or per org
29+
- `auth.protect({ feature: "org:my-feature" })`
30+
- `auth.protect({ feature: "user:my-feature" })`
31+
- `auth.protect({ plan: "user:my-plan" })`
32+
- `auth.protect({ plan: "org:my-plan" })`
33+
34+
35+
## `<Protect />`
36+
37+
### Plan
38+
- `<Protect plan="my-plan" />`
39+
40+
### Feature
41+
- `<Protect feature="my-feature" />`
42+
43+
### Scoped per user or per org
44+
- `<Protect feature="org:my-feature" />`
45+
- `<Protect feature="user:my-feature" />`
46+
- `<Protect plan="org:my-plan" />`
47+
- `<Protect plan="user:my-plan" />`
48+
49+
50+
## `useAuth()`
51+
### Plan
52+
- `useAuth().has({ plan: "my-plan" })`
53+
54+
### Feature
55+
- `useAuth().has({ feature: "my-feature" })`
56+
57+
### Scoped per user or per org
58+
- `useAuth().has({ feature: "org:my-feature" })`
59+
- `useAuth().has({ feature: "user:my-feature" })`
60+
- `useAuth().has({ plan: "user:my-plan" })`
61+
- `useAuth().has({ plan: "org:my-plan" })`

packages/backend/src/jwt/verifyJwt.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export function decodeJwt(token: string): JwtReturnType<Jwt, TokenVerificationEr
7676
// More info at https://stackoverflow.com/questions/54062583/how-to-verify-a-signed-jwt-with-subtlecrypto-of-the-web-crypto-API
7777
const header = JSON.parse(decoder.decode(base64url.parse(rawHeader, { loose: true })));
7878
const payload = JSON.parse(decoder.decode(base64url.parse(rawPayload, { loose: true })));
79+
7980
const signature = base64url.parse(rawSignature, { loose: true });
8081

8182
const data = {

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

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,146 @@ describe('signedInAuthObject', () => {
3131
const token = await authObject.getToken();
3232
expect(token).toBe('token');
3333
});
34+
35+
describe('JWT v1', () => {
36+
it('has() for orgs', () => {
37+
const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext;
38+
39+
const partialJwtPayload = {
40+
___raw: 'raw',
41+
act: { sub: 'actor' },
42+
sid: 'sessionId',
43+
org_id: 'orgId',
44+
org_role: 'org:admin',
45+
org_slug: 'orgSlug',
46+
org_permissions: ['org:f1:read', 'org:f2:manage'],
47+
sub: 'userId',
48+
} as Partial<JwtPayload>;
49+
50+
const authObject = signedInAuthObject(mockAuthenticateContext, 'token', partialJwtPayload as JwtPayload);
51+
52+
expect(authObject.has({ role: 'org:admin' })).toBe(true);
53+
expect(authObject.has({ permission: 'org:f1:read' })).toBe(true);
54+
expect(authObject.has({ permission: 'org:f1' })).toBe(false);
55+
expect(authObject.has({ permission: 'org:f2:manage' })).toBe(true);
56+
expect(authObject.has({ permission: 'org:f2' })).toBe(false);
57+
58+
expect(authObject.has({ feature: 'org:reservations' })).toBe(false);
59+
expect(authObject.has({ feature: 'org:impersonation' })).toBe(false);
60+
});
61+
});
62+
63+
describe('JWT v2', () => {
64+
it('has() for orgs', () => {
65+
const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext;
66+
67+
const partialJwtPayload = {
68+
v: 2,
69+
___raw: 'raw',
70+
act: { sub: 'actor' },
71+
sid: 'sessionId',
72+
fea: 'o:reservations,o:impersonation',
73+
o: {
74+
id: 'orgId',
75+
rol: 'admin',
76+
slg: 'orgSlug',
77+
per: 'read,manage',
78+
fpm: '3',
79+
},
80+
81+
sub: 'userId',
82+
} as Partial<JwtPayload>;
83+
84+
const authObject = signedInAuthObject(mockAuthenticateContext, 'token', partialJwtPayload as JwtPayload);
85+
86+
expect(authObject.has({ role: 'org:admin' })).toBe(true);
87+
expect(authObject.has({ permission: 'org:reservations:read' })).toBe(true);
88+
expect(authObject.has({ permission: 'org:reservations' })).toBe(false);
89+
expect(authObject.has({ permission: 'org:reservations:manage' })).toBe(true);
90+
expect(authObject.has({ permission: 'org:reservations' })).toBe(false);
91+
expect(authObject.has({ permission: 'org:impersonation:read' })).toBe(false);
92+
expect(authObject.has({ permission: 'org:impersonation:manage' })).toBe(false);
93+
94+
expect(authObject.has({ feature: 'org:reservations' })).toBe(true);
95+
expect(authObject.has({ feature: 'org:impersonation' })).toBe(true);
96+
});
97+
98+
it('has() for billing with scopes', () => {
99+
const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext;
100+
101+
const partialJwtPayload = {
102+
v: 2,
103+
___raw: 'raw',
104+
act: { sub: 'actor' },
105+
sid: 'sessionId',
106+
fea: 'o:reservations,u:dashboard,uo:support-chat,o:impersonation',
107+
o: {
108+
id: 'orgId',
109+
rol: 'member',
110+
slg: 'orgSlug',
111+
per: 'read,manage',
112+
fpm: '2,3',
113+
},
114+
pla: 'u:pro,o:business',
115+
sub: 'userId',
116+
} as Partial<JwtPayload>;
117+
118+
const authObject = signedInAuthObject(mockAuthenticateContext, 'token', partialJwtPayload as JwtPayload);
119+
120+
expect(authObject.has({ permission: 'org:reservations:read' })).toBe(true);
121+
expect(authObject.has({ permission: 'org:reservations:manage' })).toBe(false);
122+
123+
expect(authObject.has({ permission: 'org:support-chat:read' })).toBe(true);
124+
expect(authObject.has({ permission: 'org:support-chat:manage' })).toBe(true);
125+
126+
expect(authObject.has({ permission: 'u:dashboard:manage' })).toBe(false);
127+
expect(authObject.has({ permission: 'u:dashboard:read' })).toBe(false);
128+
129+
expect(authObject.has({ feature: 'org:reservations' })).toBe(true);
130+
expect(authObject.has({ feature: 'user:reservations' })).toBe(false);
131+
expect(authObject.has({ feature: 'org:impersonation' })).toBe(true);
132+
expect(authObject.has({ feature: 'user:impersonation' })).toBe(false);
133+
expect(authObject.has({ feature: 'org:dashboard' })).toBe(false);
134+
expect(authObject.has({ feature: 'user:dashboard' })).toBe(true);
135+
expect(authObject.has({ feature: 'org:support-chat' })).toBe(true);
136+
expect(authObject.has({ feature: 'user:support-chat' })).toBe(true);
137+
138+
expect(authObject.has({ plan: 'org:business' })).toBe(true);
139+
expect(authObject.has({ plan: 'user:business' })).toBe(false);
140+
141+
expect(authObject.has({ plan: 'org:pro' })).toBe(false);
142+
expect(authObject.has({ plan: 'user:pro' })).toBe(true);
143+
});
144+
145+
it('has() for billing without scopes', () => {
146+
const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext;
147+
148+
const partialJwtPayload = {
149+
v: 2,
150+
___raw: 'raw',
151+
act: { sub: 'actor' },
152+
sid: 'sessionId',
153+
fea: 'o:reservations,u:dashboard,uo:support-chat,o:impersonation',
154+
o: {
155+
id: 'orgId',
156+
rol: 'member',
157+
slg: 'orgSlug',
158+
per: 'read,manage',
159+
fpm: '2,3',
160+
},
161+
pla: 'u:pro,o:business',
162+
sub: 'userId',
163+
} as Partial<JwtPayload>;
164+
165+
const authObject = signedInAuthObject(mockAuthenticateContext, 'token', partialJwtPayload as JwtPayload);
166+
167+
expect(authObject.has({ feature: 'reservations' })).toBe(true); // because the org has it.
168+
expect(authObject.has({ feature: 'dashboard' })).toBe(true); // because the user has it.
169+
expect(authObject.has({ feature: 'pro' })).toBe(false); // `pro` is a plan
170+
expect(authObject.has({ feature: 'impersonation' })).toBe(true); // because the org has it.
171+
172+
expect(authObject.has({ plan: 'pro' })).toBe(true); // because the user has it.
173+
expect(authObject.has({ plan: 'business' })).toBe(true); // because the org has it.
174+
});
175+
});
34176
});

packages/backend/src/tokens/authObjects.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,15 @@ export function signedInAuthObject(
9797
orgPermissions,
9898
factorVerificationAge,
9999
getToken,
100-
has: createCheckAuthorization({ orgId, orgRole, orgPermissions, userId, factorVerificationAge }),
100+
has: createCheckAuthorization({
101+
orgId,
102+
orgRole,
103+
orgPermissions,
104+
userId,
105+
factorVerificationAge,
106+
features: (sessionClaims.fea as string) || '',
107+
plans: (sessionClaims.pla as string) || '',
108+
}),
101109
debug: createDebug({ ...authenticateContext, sessionToken }),
102110
};
103111
}

0 commit comments

Comments
 (0)