Skip to content

Commit 248142a

Browse files
authoredSep 12, 2024··
feat(clerk-js,clerk-react,types,nextjs): Support assurance in has() (#4118)
1 parent 8cecbe8 commit 248142a

File tree

10 files changed

+839
-91
lines changed

10 files changed

+839
-91
lines changed
 

‎.changeset/red-pens-rest.md

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@clerk/clerk-js": minor
3+
"@clerk/backend": minor
4+
"@clerk/shared": minor
5+
"@clerk/clerk-react": minor
6+
"@clerk/types": minor
7+
---
8+
9+
Experimental support for `has()` with assurance.
10+
Example usage:
11+
```ts
12+
has({
13+
__experimental_assurance: {
14+
level: 'L2.secondFactor',
15+
maxAge: 'A1.10min'
16+
}
17+
})
18+
```
19+
20+
Created a shared utility called `createCheckAuthorization` exported from `@clerk/shared`

‎packages/backend/src/tokens/authObjects.ts

+2-32
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createCheckAuthorization } from '@clerk/shared/authorization';
12
import type {
23
ActClaim,
34
CheckAuthorizationWithCustomPermissions,
@@ -123,7 +124,7 @@ export function signedInAuthObject(
123124
orgPermissions,
124125
__experimental_factorVerificationAge,
125126
getToken,
126-
has: createHasAuthorization({ orgId, orgRole, orgPermissions, userId }),
127+
has: createCheckAuthorization({ orgId, orgRole, orgPermissions, userId, __experimental_factorVerificationAge }),
127128
debug: createDebug({ ...authenticateContext, sessionToken }),
128129
};
129130
}
@@ -182,34 +183,3 @@ const createGetToken: CreateGetToken = params => {
182183
return sessionToken;
183184
};
184185
};
185-
186-
const createHasAuthorization = (options: {
187-
userId: string;
188-
orgId: string | undefined;
189-
orgRole: string | undefined;
190-
orgPermissions: string[] | undefined;
191-
}): CheckAuthorizationWithCustomPermissions => {
192-
const { orgId, orgRole, userId, orgPermissions } = options;
193-
194-
return params => {
195-
if (!params?.permission && !params?.role) {
196-
throw new Error(
197-
'Missing parameters. `has` from `auth` or `getAuth` requires a permission or role key to be passed. Example usage: `has({permission: "org:posts:edit"`',
198-
);
199-
}
200-
201-
if (!orgId || !userId || !orgRole || !orgPermissions) {
202-
return false;
203-
}
204-
205-
if (params.permission) {
206-
return orgPermissions.includes(params.permission);
207-
}
208-
209-
if (params.role) {
210-
return orgRole === params.role;
211-
}
212-
213-
return false;
214-
};
215-
};

‎packages/clerk-js/src/core/resources/Session.ts

+9-24
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { runWithExponentialBackOff } from '@clerk/shared';
2+
import { createCheckAuthorization } from '@clerk/shared/authorization';
23
import { is4xxError } from '@clerk/shared/error';
34
import type {
45
__experimental_SessionVerificationJSON,
@@ -88,31 +89,15 @@ export class Session extends BaseResource implements SessionResource {
8889
};
8990

9091
checkAuthorization: CheckAuthorization = params => {
91-
// if there is no active organization user can not be authorized
92-
if (!this.lastActiveOrganizationId || !this.user) {
93-
return false;
94-
}
95-
96-
// loop through organizationMemberships from client piggybacking
97-
const orgMemberships = this.user.organizationMemberships || [];
92+
const orgMemberships = this.user?.organizationMemberships || [];
9893
const activeMembership = orgMemberships.find(mem => mem.organization.id === this.lastActiveOrganizationId);
99-
100-
// Based on FAPI this should never happen, but we handle it anyway
101-
if (!activeMembership) {
102-
return false;
103-
}
104-
105-
const activeOrganizationPermissions = activeMembership.permissions;
106-
const activeOrganizationRole = activeMembership.role;
107-
108-
if (params.permission) {
109-
return activeOrganizationPermissions.includes(params.permission);
110-
}
111-
if (params.role) {
112-
return activeOrganizationRole === params.role;
113-
}
114-
115-
return false;
94+
return createCheckAuthorization({
95+
userId: this.user?.id,
96+
__experimental_factorVerificationAge: this.__experimental_factorVerificationAge,
97+
orgId: activeMembership?.id,
98+
orgRole: activeMembership?.role,
99+
orgPermissions: activeMembership?.permissions,
100+
})(params);
116101
};
117102

118103
#hydrateCache = (token: TokenResource | null) => {

‎packages/clerk-js/src/core/resources/__tests__/Session.test.ts

+546-2
Large diffs are not rendered by default.

‎packages/clerk-js/src/core/test/fixtures.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,10 @@ export const createExternalAccount = (params?: Partial<ExternalAccountJSON>): Ex
128128
} as ExternalAccountJSON;
129129
};
130130

131-
export const createUser = (params: WithUserParams): UserJSON => {
131+
export const createUser = (params?: WithUserParams): UserJSON => {
132132
const res = {
133133
object: 'user',
134-
id: params.id,
134+
id: params?.id || 'user_123',
135135
primary_email_address_id: '',
136136
primary_phone_number_id: '',
137137
primary_web3_wallet_id: '',
@@ -152,16 +152,16 @@ export const createUser = (params: WithUserParams): UserJSON => {
152152
updated_at: new Date().getTime(),
153153
created_at: new Date().getTime(),
154154
...params,
155-
email_addresses: (params.email_addresses || []).map(e =>
155+
email_addresses: (params?.email_addresses || []).map(e =>
156156
typeof e === 'string' ? createEmail({ email_address: e }) : createEmail(e),
157157
),
158-
phone_numbers: (params.phone_numbers || []).map(n =>
158+
phone_numbers: (params?.phone_numbers || []).map(n =>
159159
typeof n === 'string' ? createPhoneNumber({ phone_number: n }) : createPhoneNumber(n),
160160
),
161-
external_accounts: (params.external_accounts || []).map(p =>
161+
external_accounts: (params?.external_accounts || []).map(p =>
162162
typeof p === 'string' ? createExternalAccount({ provider: p }) : createExternalAccount(p),
163163
),
164-
organization_memberships: (params.organization_memberships || []).map(o =>
164+
organization_memberships: (params?.organization_memberships || []).map(o =>
165165
typeof o === 'string' ? createOrganizationMembership({ name: o }) : createOrganizationMembership(o),
166166
),
167167
} as UserJSON;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { expectTypeOf } from 'expect-type';
2+
3+
import type { useAuth } from '../useAuth';
4+
5+
type HasFunction = Exclude<ReturnType<typeof useAuth>['has'], undefined>;
6+
type ParamsOfHas = Parameters<HasFunction>[0];
7+
8+
describe('useAuth type tests', () => {
9+
describe('has', () => {
10+
it('has({}) is allowed', () => {
11+
expectTypeOf({} as const).toMatchTypeOf<ParamsOfHas>();
12+
});
13+
14+
it('has({role: string}) is allowed', () => {
15+
expectTypeOf({ role: 'org:admin' }).toMatchTypeOf<ParamsOfHas>();
16+
});
17+
18+
it('has({role: string, permission: string}) is NOT allowed', () => {
19+
expectTypeOf({ role: 'org:admin', permission: 'some-perm' }).not.toMatchTypeOf<ParamsOfHas>();
20+
});
21+
22+
it('has with role and assurance is allowed', () => {
23+
expectTypeOf({
24+
role: 'org:admin',
25+
__experimental_assurance: {
26+
level: 'L1.firstFactor',
27+
maxAge: 'A1.10min',
28+
},
29+
} as const).toMatchTypeOf<ParamsOfHas>();
30+
});
31+
32+
it('has with permission and assurance is allowed', () => {
33+
expectTypeOf({
34+
permission: 'org:edit:posts',
35+
__experimental_assurance: {
36+
level: 'L1.firstFactor',
37+
maxAge: 'A1.10min',
38+
},
39+
} as const).toMatchTypeOf<ParamsOfHas>();
40+
});
41+
42+
it('has({assurance: {level, maxAge}}) is allowed', () => {
43+
expectTypeOf({
44+
__experimental_assurance: {
45+
level: 'L1.firstFactor',
46+
maxAge: 'A1.10min',
47+
},
48+
} as const).toMatchTypeOf<ParamsOfHas>();
49+
});
50+
51+
it('assurance with other strings as maxAge should throw', () => {
52+
expectTypeOf({
53+
__experimental_assurance: {
54+
level: 'L1.firstFactor',
55+
maxAge: 'some-value',
56+
},
57+
} as const).not.toMatchTypeOf<ParamsOfHas>();
58+
});
59+
60+
it('assurance with number as maxAge should throw', () => {
61+
expectTypeOf({
62+
__experimental_assurance: {
63+
level: 'L1.firstFactor',
64+
maxAge: 1000,
65+
},
66+
} as const).not.toMatchTypeOf<ParamsOfHas>();
67+
});
68+
69+
it('assurance with other strings as level should throw', () => {
70+
expectTypeOf({
71+
__experimental_assurance: {
72+
level: 'some-factor',
73+
maxAge: 'A1.10min',
74+
},
75+
} as const).not.toMatchTypeOf<ParamsOfHas>();
76+
});
77+
78+
it('assurance with number as level should throw', () => {
79+
expectTypeOf({
80+
__experimental_assurance: {
81+
level: 2,
82+
maxAge: 'A1.10min',
83+
},
84+
} as const).not.toMatchTypeOf<ParamsOfHas>();
85+
});
86+
});
87+
});

‎packages/react/src/hooks/useAuth.ts

+15-23
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createCheckAuthorization } from '@clerk/shared/authorization';
12
import type {
23
ActJWTClaim,
34
CheckAuthorizationWithCustomPermissions,
@@ -10,12 +11,12 @@ import { useCallback } from 'react';
1011
import { useAuthContext } from '../contexts/AuthContext';
1112
import { useIsomorphicClerkContext } from '../contexts/IsomorphicClerkContext';
1213
import { errorThrower } from '../errors/errorThrower';
13-
import { invalidStateError, useAuthHasRequiresRoleOrPermission } from '../errors/messages';
14+
import { invalidStateError } from '../errors/messages';
1415
import { useAssertWrappedByClerkProvider } from './useAssertWrappedByClerkProvider';
1516
import { createGetToken, createSignOut } from './utils';
1617

1718
type CheckAuthorizationSignedOut = undefined;
18-
type CheckAuthorizationWithoutOrgOrUser = (params?: Parameters<CheckAuthorizationWithCustomPermissions>[0]) => false;
19+
type CheckAuthorizationWithoutOrgOrUser = (params: Parameters<CheckAuthorizationWithCustomPermissions>[0]) => false;
1920

2021
type UseAuthReturn =
2122
| {
@@ -53,7 +54,7 @@ type UseAuthReturn =
5354
orgId: null;
5455
orgRole: null;
5556
orgSlug: null;
56-
has: CheckAuthorizationWithoutOrgOrUser;
57+
has: CheckAuthorizationWithCustomPermissions;
5758
signOut: SignOut;
5859
getToken: GetToken;
5960
}
@@ -112,33 +113,24 @@ type UseAuth = () => UseAuthReturn;
112113
export const useAuth: UseAuth = () => {
113114
useAssertWrappedByClerkProvider('useAuth');
114115

115-
const { sessionId, userId, actor, orgId, orgRole, orgSlug, orgPermissions } = useAuthContext();
116+
const { sessionId, userId, actor, orgId, orgRole, orgSlug, orgPermissions, __experimental_factorVerificationAge } =
117+
useAuthContext();
116118
const isomorphicClerk = useIsomorphicClerkContext();
117119

118120
const getToken: GetToken = useCallback(createGetToken(isomorphicClerk), [isomorphicClerk]);
119121
const signOut: SignOut = useCallback(createSignOut(isomorphicClerk), [isomorphicClerk]);
120122

121123
const has = useCallback(
122124
(params: Parameters<CheckAuthorizationWithCustomPermissions>[0]) => {
123-
if (!params?.permission && !params?.role) {
124-
errorThrower.throw(useAuthHasRequiresRoleOrPermission);
125-
}
126-
127-
if (!orgId || !userId || !orgRole || !orgPermissions) {
128-
return false;
129-
}
130-
131-
if (params.permission) {
132-
return orgPermissions.includes(params.permission);
133-
}
134-
135-
if (params.role) {
136-
return orgRole === params.role;
137-
}
138-
139-
return false;
125+
return createCheckAuthorization({
126+
userId,
127+
orgId,
128+
orgRole,
129+
orgPermissions,
130+
__experimental_factorVerificationAge,
131+
})(params);
140132
},
141-
[orgId, orgRole, userId, orgPermissions],
133+
[userId, __experimental_factorVerificationAge, orgId, orgRole, orgPermissions],
142134
);
143135

144136
if (sessionId === undefined && userId === undefined) {
@@ -199,7 +191,7 @@ export const useAuth: UseAuth = () => {
199191
orgId: null,
200192
orgRole: null,
201193
orgSlug: null,
202-
has: () => false,
194+
has,
203195
signOut,
204196
getToken,
205197
};

‎packages/shared/src/authorization.ts

+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import type {
2+
__experimental_SessionVerificationLevel,
3+
__experimental_SessionVerificationMaxAge,
4+
CheckAuthorizationWithCustomPermissions,
5+
OrganizationCustomPermissionKey,
6+
OrganizationCustomRoleKey,
7+
} from '@clerk/types';
8+
9+
type MaxAgeMap = Record<__experimental_SessionVerificationMaxAge, number>;
10+
type AuthorizationOptions = {
11+
userId: string | null | undefined;
12+
orgId: string | null | undefined;
13+
orgRole: string | null | undefined;
14+
orgPermissions: string[] | null | undefined;
15+
__experimental_factorVerificationAge: [number, number] | null;
16+
};
17+
18+
type CheckOrgAuthorization = (
19+
params: { role?: OrganizationCustomRoleKey; permission?: OrganizationCustomPermissionKey },
20+
{ orgId, orgRole, orgPermissions }: AuthorizationOptions,
21+
) => boolean | null;
22+
23+
type CheckStepUpAuthorization = (
24+
params: {
25+
__experimental_assurance?: {
26+
level: __experimental_SessionVerificationLevel;
27+
maxAge: __experimental_SessionVerificationMaxAge;
28+
};
29+
},
30+
{ __experimental_factorVerificationAge }: AuthorizationOptions,
31+
) => boolean | null;
32+
33+
const MAX_AGE_TO_MINUTES: MaxAgeMap = {
34+
'A1.10min': 10,
35+
'A2.1hr': 60,
36+
'A3.4hr': 240, //4 * 60
37+
'A4.1day': 1440, //24 * 60,
38+
'A5.1wk': 10080, //7 * 24 * 60,
39+
};
40+
41+
const ALLOWED_MAX_AGES = new Set<__experimental_SessionVerificationMaxAge>(
42+
Object.keys(MAX_AGE_TO_MINUTES) as __experimental_SessionVerificationMaxAge[],
43+
);
44+
const ALLOWED_LEVELS = new Set<__experimental_SessionVerificationLevel>([
45+
'L1.firstFactor',
46+
'L2.secondFactor',
47+
'L3.multiFactor',
48+
]);
49+
50+
// Helper functions
51+
const isValidMaxAge = (maxAge: __experimental_SessionVerificationMaxAge) => ALLOWED_MAX_AGES.has(maxAge);
52+
const isValidLevel = (level: __experimental_SessionVerificationLevel) => ALLOWED_LEVELS.has(level);
53+
54+
/**
55+
* Checks if a user has the required organization-level authorization.
56+
* Verifies if the user has the specified role or permission within their organization.
57+
* @returns null, if unable to determine due to missing data or unspecified role/permission.
58+
*/
59+
const checkOrgAuthorization: CheckOrgAuthorization = (params, options) => {
60+
const { orgId, orgRole, orgPermissions } = options;
61+
if (!params.role && !params.permission) {
62+
return null;
63+
}
64+
if (!orgId || !orgRole || !orgPermissions) {
65+
return null;
66+
}
67+
68+
if (params.permission) {
69+
return orgPermissions.includes(params.permission);
70+
}
71+
if (params.role) {
72+
return orgRole === params.role;
73+
}
74+
return null;
75+
};
76+
77+
/**
78+
* Evaluates if the user meets step-up authentication requirements.
79+
* Compares the user's factor verification ages against the specified maxAge.
80+
* Handles different verification levels (first factor, second factor, multi-factor).
81+
* @returns null, if requirements or verification data are missing.
82+
*/
83+
const checkStepUpAuthorization: CheckStepUpAuthorization = (params, { __experimental_factorVerificationAge }) => {
84+
if (!params.__experimental_assurance || !__experimental_factorVerificationAge) {
85+
return null;
86+
}
87+
const { level, maxAge } = params.__experimental_assurance;
88+
89+
if (!isValidLevel(level) || !isValidMaxAge(maxAge)) {
90+
return null;
91+
}
92+
93+
const [factor1Age, factor2Age] = __experimental_factorVerificationAge;
94+
const maxAgeInMinutes = MAX_AGE_TO_MINUTES[maxAge];
95+
96+
// -1 indicates the factor group (1fa,2fa) is not enabled
97+
// -1 for 1fa is not a valid scenario, but we need to make sure we handle it properly
98+
const isValidFactor1 = factor1Age !== -1 ? maxAgeInMinutes > factor1Age : null;
99+
const isValidFactor2 = factor2Age !== -1 ? maxAgeInMinutes > factor2Age : null;
100+
101+
switch (level) {
102+
case 'L1.firstFactor':
103+
return isValidFactor1;
104+
case 'L2.secondFactor':
105+
return factor2Age !== -1 ? isValidFactor2 : isValidFactor1;
106+
case 'L3.multiFactor':
107+
return factor2Age === -1 ? isValidFactor1 : isValidFactor1 && isValidFactor2;
108+
}
109+
};
110+
111+
/**
112+
* Creates a function for comprehensive user authorization checks.
113+
* Combines organization-level and step-up authentication checks.
114+
* The returned function authorizes if both checks pass, or if at least one passes
115+
* when the other is indeterminate. Fails if userId is missing.
116+
*/
117+
export const createCheckAuthorization = (options: AuthorizationOptions): CheckAuthorizationWithCustomPermissions => {
118+
return (params): boolean => {
119+
if (!options.userId) {
120+
return false;
121+
}
122+
123+
const orgAuthorization = checkOrgAuthorization(params, options);
124+
const stepUpAuthorization = checkStepUpAuthorization(params, options);
125+
126+
if ([orgAuthorization, stepUpAuthorization].some(a => a === null)) {
127+
return [orgAuthorization, stepUpAuthorization].some(a => a === true);
128+
}
129+
130+
return [orgAuthorization, stepUpAuthorization].every(a => a === true);
131+
};
132+
};

‎packages/shared/subpaths.mjs

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// We have to polyfill our "exports" subpaths :cry:
33

44
export const subpathNames = [
5+
'authorization',
56
'browser',
67
'callWithRetry',
78
'color',

‎packages/types/src/session.ts

+21-4
Original file line numberDiff line numberDiff line change
@@ -28,27 +28,44 @@ export type CheckAuthorizationFn<Params> = (isAuthorizedParams: Params) => boole
2828
export type CheckAuthorizationWithCustomPermissions =
2929
CheckAuthorizationFn<CheckAuthorizationParamsWithCustomPermissions>;
3030

31-
export type CheckAuthorizationParamsWithCustomPermissions =
31+
export type CheckAuthorizationParamsWithCustomPermissions = (
3232
| {
3333
role: OrganizationCustomRoleKey;
3434
permission?: never;
3535
}
3636
| {
3737
role?: never;
3838
permission: OrganizationCustomPermissionKey;
39-
};
39+
}
40+
| { role?: never; permission?: never }
41+
) & {
42+
__experimental_assurance?: {
43+
level: __experimental_SessionVerificationLevel;
44+
maxAge: __experimental_SessionVerificationMaxAge;
45+
};
46+
};
4047

4148
export type CheckAuthorization = CheckAuthorizationFn<CheckAuthorizationParams>;
4249

43-
type CheckAuthorizationParams =
50+
type CheckAuthorizationParams = (
4451
| {
4552
role: OrganizationCustomRoleKey;
4653
permission?: never;
4754
}
4855
| {
4956
role?: never;
5057
permission: OrganizationPermissionKey;
51-
};
58+
}
59+
| {
60+
role?: never;
61+
permission?: never;
62+
}
63+
) & {
64+
__experimental_assurance?: {
65+
level: __experimental_SessionVerificationLevel;
66+
maxAge: __experimental_SessionVerificationMaxAge;
67+
};
68+
};
5269

5370
export interface SessionResource extends ClerkResource {
5471
id: string;

0 commit comments

Comments
 (0)
Please sign in to comment.