@chidchanun/bcp 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,11 @@ import {
4
4
  type AuthSession,
5
5
  type AuthUser,
6
6
  } from "./auth.js";
7
+ import {
8
+ hasPermission,
9
+ type AuthorizationMatch,
10
+ type PermissionRequirement,
11
+ } from "./authorization.js";
7
12
  import {
8
13
  redirect,
9
14
  type RedirectStatus,
@@ -31,7 +36,15 @@ extends AuthOptions {
31
36
  export interface RequireRoleOptions
32
37
  extends RequireAuthOptions {
33
38
  roleField?: string;
34
- match?: "any" | "all";
39
+ match?: AuthorizationMatch;
40
+ forbiddenRedirectTo?: string | URL | null;
41
+ forbiddenRedirectStatus?: RedirectStatus;
42
+ }
43
+
44
+ export interface RequirePermissionOptions
45
+ extends RequireAuthOptions {
46
+ permissionField?: string;
47
+ match?: AuthorizationMatch;
35
48
  forbiddenRedirectTo?: string | URL | null;
36
49
  forbiddenRedirectStatus?: RedirectStatus;
37
50
  }
@@ -186,25 +199,59 @@ export async function requireRole<
186
199
  )
187
200
  );
188
201
 
189
- if (!allowed) {
190
- if (
191
- forbiddenRedirectTo !== null
192
- ) {
193
- return redirect(
194
- forbiddenRedirectTo,
195
- forbiddenRedirectStatus
196
- );
197
- }
202
+ return finalizeAuthorization(
203
+ allowed,
204
+ authenticated,
205
+ forbiddenRedirectTo,
206
+ forbiddenRedirectStatus
207
+ );
208
+ }
198
209
 
199
- return new Response(
200
- "Forbidden",
210
+ export async function requirePermission<
211
+ TUser extends AuthUser = AuthUser,
212
+ TData extends object = Record<string, never>
213
+ >(
214
+ requiredPermission:
215
+ PermissionRequirement,
216
+ options:
217
+ RequirePermissionOptions = {}
218
+ ): Promise<AuthGuardResult<TUser, TData>> {
219
+ const {
220
+ permissionField =
221
+ "permissions",
222
+ match = "any",
223
+ forbiddenRedirectTo = null,
224
+ forbiddenRedirectStatus = 303,
225
+ ...authOptions
226
+ } = options;
227
+ const authenticated =
228
+ await requireAuth<TUser, TData>(
229
+ authOptions
230
+ );
231
+
232
+ if (
233
+ authenticated instanceof Response
234
+ ) {
235
+ return authenticated;
236
+ }
237
+
238
+ const allowed =
239
+ hasPermission(
240
+ authenticated.auth.user,
241
+ requiredPermission,
201
242
  {
202
- status: 403,
243
+ field:
244
+ permissionField,
245
+ match,
203
246
  }
204
247
  );
205
- }
206
248
 
207
- return authenticated;
249
+ return finalizeAuthorization(
250
+ allowed,
251
+ authenticated,
252
+ forbiddenRedirectTo,
253
+ forbiddenRedirectStatus
254
+ );
208
255
  }
209
256
 
210
257
  export function createAuthGuard<
@@ -245,6 +292,22 @@ export function createRoleGuard<
245
292
  );
246
293
  }
247
294
 
295
+ export function createPermissionGuard<
296
+ TUser extends AuthUser = AuthUser,
297
+ TData extends object = Record<string, never>
298
+ >(
299
+ requiredPermission:
300
+ PermissionRequirement,
301
+ options:
302
+ RequirePermissionOptions = {}
303
+ ): AuthGuardFunction<TUser, TData> {
304
+ return () =>
305
+ requirePermission<TUser, TData>(
306
+ requiredPermission,
307
+ options
308
+ );
309
+ }
310
+
248
311
  export function getGuardAuth<
249
312
  TUser extends AuthUser = AuthUser,
250
313
  TData extends object = Record<string, never>
@@ -293,6 +356,39 @@ export function getGuardAuth<
293
356
  AuthSession<TUser, TData>;
294
357
  }
295
358
 
359
+ function finalizeAuthorization<
360
+ TUser extends AuthUser,
361
+ TData extends object
362
+ >(
363
+ allowed: boolean,
364
+ authenticated:
365
+ AuthGuardData<TUser, TData>,
366
+ forbiddenRedirectTo:
367
+ string | URL | null,
368
+ forbiddenRedirectStatus:
369
+ RedirectStatus
370
+ ): Promise<AuthGuardResult<TUser, TData>> | AuthGuardData<TUser, TData> | Response {
371
+ if (allowed) {
372
+ return authenticated;
373
+ }
374
+
375
+ if (
376
+ forbiddenRedirectTo !== null
377
+ ) {
378
+ return redirect(
379
+ forbiddenRedirectTo,
380
+ forbiddenRedirectStatus
381
+ );
382
+ }
383
+
384
+ return new Response(
385
+ "Forbidden",
386
+ {
387
+ status: 403,
388
+ }
389
+ );
390
+ }
391
+
296
392
  function normalizeRequiredRoles(
297
393
  value: RequiredRole
298
394
  ): string[] {
@@ -0,0 +1,368 @@
1
+ export type AuthorizationMatch =
2
+ | "any"
3
+ | "all";
4
+
5
+ export type PermissionRequirement =
6
+ | string
7
+ | readonly string[];
8
+
9
+ export interface PermissionCheckOptions {
10
+ field?: string;
11
+ match?: AuthorizationMatch;
12
+ }
13
+
14
+ export interface AuthorizationContext<
15
+ TUser extends object = Record<string, unknown>,
16
+ TResource = unknown,
17
+ TData = unknown
18
+ > {
19
+ user: TUser;
20
+ resource?: TResource;
21
+ data?: TData;
22
+ }
23
+
24
+ export type AuthorizationPolicy<
25
+ TUser extends object = Record<string, unknown>,
26
+ TResource = unknown,
27
+ TData = unknown
28
+ > = (
29
+ context: AuthorizationContext<
30
+ TUser,
31
+ TResource,
32
+ TData
33
+ >
34
+ ) => boolean | Promise<boolean>;
35
+
36
+ export class AuthorizationError
37
+ extends Error {
38
+ readonly status = 403;
39
+ readonly code = "FORBIDDEN";
40
+
41
+ constructor(
42
+ message = "Forbidden"
43
+ ) {
44
+ super(message);
45
+ this.name =
46
+ "AuthorizationError";
47
+ }
48
+ }
49
+
50
+ export function getUserPermissions(
51
+ user: object,
52
+ field = "permissions"
53
+ ): string[] {
54
+ const normalizedField =
55
+ normalizeField(
56
+ field
57
+ );
58
+ const value =
59
+ (
60
+ user as
61
+ Record<string, unknown>
62
+ )[normalizedField];
63
+
64
+ if (
65
+ typeof value === "string"
66
+ ) {
67
+ const permission =
68
+ value.trim();
69
+
70
+ return permission
71
+ ? [permission]
72
+ : [];
73
+ }
74
+
75
+ if (
76
+ !Array.isArray(
77
+ value
78
+ )
79
+ ) {
80
+ return [];
81
+ }
82
+
83
+ return Array.from(
84
+ new Set(
85
+ value
86
+ .filter(
87
+ (
88
+ permission
89
+ ): permission is string =>
90
+ typeof permission ===
91
+ "string"
92
+ )
93
+ .map(
94
+ (permission) =>
95
+ permission.trim()
96
+ )
97
+ .filter(Boolean)
98
+ )
99
+ );
100
+ }
101
+
102
+ export function hasPermission(
103
+ user: object,
104
+ required:
105
+ PermissionRequirement,
106
+ options:
107
+ PermissionCheckOptions = {}
108
+ ): boolean {
109
+ const requiredPermissions =
110
+ normalizePermissions(
111
+ required
112
+ );
113
+ const assignedPermissions =
114
+ getUserPermissions(
115
+ user,
116
+ options.field
117
+ );
118
+ const match =
119
+ options.match ??
120
+ "any";
121
+
122
+ assertAuthorizationMatch(
123
+ match
124
+ );
125
+
126
+ return match === "all"
127
+ ? requiredPermissions.every(
128
+ (permission) =>
129
+ assignedPermissions.includes(
130
+ permission
131
+ )
132
+ )
133
+ : requiredPermissions.some(
134
+ (permission) =>
135
+ assignedPermissions.includes(
136
+ permission
137
+ )
138
+ );
139
+ }
140
+
141
+ export function assertPermission(
142
+ user: object,
143
+ required:
144
+ PermissionRequirement,
145
+ options:
146
+ PermissionCheckOptions = {}
147
+ ): void {
148
+ if (
149
+ !hasPermission(
150
+ user,
151
+ required,
152
+ options
153
+ )
154
+ ) {
155
+ throw new AuthorizationError();
156
+ }
157
+ }
158
+
159
+ export function defineAuthorizationPolicy<
160
+ TUser extends object,
161
+ TResource = unknown,
162
+ TData = unknown
163
+ >(
164
+ policy:
165
+ AuthorizationPolicy<
166
+ TUser,
167
+ TResource,
168
+ TData
169
+ >
170
+ ): AuthorizationPolicy<
171
+ TUser,
172
+ TResource,
173
+ TData
174
+ > {
175
+ if (
176
+ typeof policy !==
177
+ "function"
178
+ ) {
179
+ throw new TypeError(
180
+ "BCP Authorization: policy must be a function."
181
+ );
182
+ }
183
+
184
+ return policy;
185
+ }
186
+
187
+ export async function can<
188
+ TUser extends object,
189
+ TResource = unknown,
190
+ TData = unknown
191
+ >(
192
+ policy:
193
+ AuthorizationPolicy<
194
+ TUser,
195
+ TResource,
196
+ TData
197
+ >,
198
+ context:
199
+ AuthorizationContext<
200
+ TUser,
201
+ TResource,
202
+ TData
203
+ >
204
+ ): Promise<boolean> {
205
+ assertAuthorizationContext(
206
+ context
207
+ );
208
+
209
+ return Boolean(
210
+ await policy(
211
+ context
212
+ )
213
+ );
214
+ }
215
+
216
+ export async function cannot<
217
+ TUser extends object,
218
+ TResource = unknown,
219
+ TData = unknown
220
+ >(
221
+ policy:
222
+ AuthorizationPolicy<
223
+ TUser,
224
+ TResource,
225
+ TData
226
+ >,
227
+ context:
228
+ AuthorizationContext<
229
+ TUser,
230
+ TResource,
231
+ TData
232
+ >
233
+ ): Promise<boolean> {
234
+ return !await can(
235
+ policy,
236
+ context
237
+ );
238
+ }
239
+
240
+ export async function authorize<
241
+ TUser extends object,
242
+ TResource = unknown,
243
+ TData = unknown
244
+ >(
245
+ policy:
246
+ AuthorizationPolicy<
247
+ TUser,
248
+ TResource,
249
+ TData
250
+ >,
251
+ context:
252
+ AuthorizationContext<
253
+ TUser,
254
+ TResource,
255
+ TData
256
+ >,
257
+ message = "Forbidden"
258
+ ): Promise<void> {
259
+ if (
260
+ !await can(
261
+ policy,
262
+ context
263
+ )
264
+ ) {
265
+ throw new AuthorizationError(
266
+ message
267
+ );
268
+ }
269
+ }
270
+
271
+ function normalizePermissions(
272
+ value:
273
+ PermissionRequirement
274
+ ): string[] {
275
+ const permissions =
276
+ typeof value === "string"
277
+ ? [value]
278
+ : [
279
+ ...value,
280
+ ];
281
+ const normalized =
282
+ permissions.map(
283
+ (permission) =>
284
+ permission.trim()
285
+ );
286
+
287
+ if (
288
+ normalized.length === 0 ||
289
+ normalized.some(
290
+ (permission) =>
291
+ permission.length === 0
292
+ )
293
+ ) {
294
+ throw new TypeError(
295
+ "BCP Authorization: required permissions must contain non-empty strings."
296
+ );
297
+ }
298
+
299
+ return Array.from(
300
+ new Set(
301
+ normalized
302
+ )
303
+ );
304
+ }
305
+
306
+ function normalizeField(
307
+ value: string
308
+ ): string {
309
+ const field =
310
+ value.trim();
311
+
312
+ if (!field) {
313
+ throw new TypeError(
314
+ "BCP Authorization: permission field must be a non-empty string."
315
+ );
316
+ }
317
+
318
+ return field;
319
+ }
320
+
321
+ function assertAuthorizationMatch(
322
+ value: string
323
+ ): asserts value is AuthorizationMatch {
324
+ if (
325
+ value !== "any" &&
326
+ value !== "all"
327
+ ) {
328
+ throw new TypeError(
329
+ "BCP Authorization: match must be either \"any\" or \"all\"."
330
+ );
331
+ }
332
+ }
333
+
334
+ function assertAuthorizationContext(
335
+ value: unknown
336
+ ): asserts value is AuthorizationContext<object> {
337
+ if (
338
+ !value ||
339
+ typeof value !== "object" ||
340
+ Array.isArray(
341
+ value
342
+ ) ||
343
+ !(
344
+ "user" in value
345
+ ) ||
346
+ !(
347
+ value as {
348
+ user?: unknown;
349
+ }
350
+ ).user ||
351
+ typeof (
352
+ value as {
353
+ user: unknown;
354
+ }
355
+ ).user !== "object" ||
356
+ Array.isArray(
357
+ (
358
+ value as {
359
+ user: unknown;
360
+ }
361
+ ).user
362
+ )
363
+ ) {
364
+ throw new TypeError(
365
+ "BCP Authorization: context must contain a user object."
366
+ );
367
+ }
368
+ }