@chidchanun/bcp 0.1.16 → 0.1.18
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.
- package/docs/README.md +325 -0
- package/docs/auth-route-guards.md +216 -0
- package/docs/middleware.md +486 -24
- package/docs/releases/0.1.17.md +36 -0
- package/docs/releases/0.1.18.md +119 -0
- package/package.json +1 -1
- package/packages/client/src/auth.ts +15 -0
- package/packages/server/src/auth-guard.ts +403 -0
- package/packages/server/src/middleware-loader.ts +34 -7
- package/packages/server/src/middleware-proxy.ts +325 -159
- package/packages/server/src/middleware.ts +544 -104
|
@@ -12,3 +12,18 @@ export {
|
|
|
12
12
|
type AuthSession,
|
|
13
13
|
type AuthUser,
|
|
14
14
|
} from "../../server/src/auth.js";
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
createAuthGuard,
|
|
18
|
+
createRoleGuard,
|
|
19
|
+
getGuardAuth,
|
|
20
|
+
requireAuth,
|
|
21
|
+
requireRole,
|
|
22
|
+
|
|
23
|
+
type AuthGuardData,
|
|
24
|
+
type AuthGuardFunction,
|
|
25
|
+
type AuthGuardResult,
|
|
26
|
+
type RequireAuthOptions,
|
|
27
|
+
type RequireRoleOptions,
|
|
28
|
+
type RequiredRole,
|
|
29
|
+
} from "../../server/src/auth-guard.js";
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import {
|
|
2
|
+
auth,
|
|
3
|
+
type AuthOptions,
|
|
4
|
+
type AuthSession,
|
|
5
|
+
type AuthUser,
|
|
6
|
+
} from "./auth.js";
|
|
7
|
+
import {
|
|
8
|
+
redirect,
|
|
9
|
+
type RedirectStatus,
|
|
10
|
+
} from "./server-response.js";
|
|
11
|
+
|
|
12
|
+
export interface AuthGuardData<
|
|
13
|
+
TUser extends AuthUser = AuthUser,
|
|
14
|
+
TData extends object = Record<string, never>
|
|
15
|
+
> extends Record<string, unknown> {
|
|
16
|
+
auth: AuthSession<TUser, TData>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RequireAuthOptions
|
|
20
|
+
extends AuthOptions {
|
|
21
|
+
redirectTo?:
|
|
22
|
+
string |
|
|
23
|
+
URL |
|
|
24
|
+
null;
|
|
25
|
+
redirectStatus?: RedirectStatus;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RequireRoleOptions
|
|
29
|
+
extends RequireAuthOptions {
|
|
30
|
+
roleField?: string;
|
|
31
|
+
match?: "any" | "all";
|
|
32
|
+
forbiddenRedirectTo?:
|
|
33
|
+
string |
|
|
34
|
+
URL |
|
|
35
|
+
null;
|
|
36
|
+
forbiddenRedirectStatus?: RedirectStatus;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type RequiredRole =
|
|
40
|
+
string |
|
|
41
|
+
readonly string[];
|
|
42
|
+
|
|
43
|
+
export type AuthGuardResult<
|
|
44
|
+
TUser extends AuthUser = AuthUser,
|
|
45
|
+
TData extends object = Record<string, never>
|
|
46
|
+
> =
|
|
47
|
+
| AuthGuardData<TUser, TData>
|
|
48
|
+
| Response;
|
|
49
|
+
|
|
50
|
+
export type AuthGuardFunction<
|
|
51
|
+
TUser extends AuthUser = AuthUser,
|
|
52
|
+
TData extends object = Record<string, never>
|
|
53
|
+
> = () => Promise<
|
|
54
|
+
AuthGuardResult<
|
|
55
|
+
TUser,
|
|
56
|
+
TData
|
|
57
|
+
>
|
|
58
|
+
>;
|
|
59
|
+
|
|
60
|
+
export async function requireAuth<
|
|
61
|
+
TUser extends AuthUser = AuthUser,
|
|
62
|
+
TData extends object = Record<string, never>
|
|
63
|
+
>(
|
|
64
|
+
options: RequireAuthOptions = {}
|
|
65
|
+
): Promise<AuthGuardResult<TUser, TData>> {
|
|
66
|
+
const {
|
|
67
|
+
redirectTo = "/login",
|
|
68
|
+
redirectStatus = 303,
|
|
69
|
+
...authOptions
|
|
70
|
+
} = options;
|
|
71
|
+
const session =
|
|
72
|
+
await auth<
|
|
73
|
+
TUser,
|
|
74
|
+
TData
|
|
75
|
+
>(
|
|
76
|
+
authOptions
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
if (!session) {
|
|
80
|
+
if (
|
|
81
|
+
redirectTo ===
|
|
82
|
+
null
|
|
83
|
+
) {
|
|
84
|
+
return new Response(
|
|
85
|
+
"Unauthorized",
|
|
86
|
+
{
|
|
87
|
+
status:
|
|
88
|
+
401,
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return redirect(
|
|
94
|
+
redirectTo,
|
|
95
|
+
redirectStatus
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
auth:
|
|
101
|
+
session,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function requireRole<
|
|
106
|
+
TUser extends AuthUser = AuthUser,
|
|
107
|
+
TData extends object = Record<string, never>
|
|
108
|
+
>(
|
|
109
|
+
requiredRole: RequiredRole,
|
|
110
|
+
options: RequireRoleOptions = {}
|
|
111
|
+
): Promise<AuthGuardResult<TUser, TData>> {
|
|
112
|
+
const roles =
|
|
113
|
+
normalizeRequiredRoles(
|
|
114
|
+
requiredRole
|
|
115
|
+
);
|
|
116
|
+
const {
|
|
117
|
+
roleField = "role",
|
|
118
|
+
match = "any",
|
|
119
|
+
forbiddenRedirectTo = null,
|
|
120
|
+
forbiddenRedirectStatus = 303,
|
|
121
|
+
...authOptions
|
|
122
|
+
} = options;
|
|
123
|
+
const authenticated =
|
|
124
|
+
await requireAuth<
|
|
125
|
+
TUser,
|
|
126
|
+
TData
|
|
127
|
+
>(
|
|
128
|
+
authOptions
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
if (
|
|
132
|
+
authenticated instanceof
|
|
133
|
+
Response
|
|
134
|
+
) {
|
|
135
|
+
return authenticated;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const assignedRoles =
|
|
139
|
+
readAssignedRoles(
|
|
140
|
+
authenticated.auth.user,
|
|
141
|
+
roleField
|
|
142
|
+
);
|
|
143
|
+
const allowed =
|
|
144
|
+
match === "all"
|
|
145
|
+
? roles.every(
|
|
146
|
+
(role) =>
|
|
147
|
+
assignedRoles.includes(
|
|
148
|
+
role
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
: roles.some(
|
|
152
|
+
(role) =>
|
|
153
|
+
assignedRoles.includes(
|
|
154
|
+
role
|
|
155
|
+
)
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
if (!allowed) {
|
|
159
|
+
if (
|
|
160
|
+
forbiddenRedirectTo !==
|
|
161
|
+
null
|
|
162
|
+
) {
|
|
163
|
+
return redirect(
|
|
164
|
+
forbiddenRedirectTo,
|
|
165
|
+
forbiddenRedirectStatus
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return new Response(
|
|
170
|
+
"Forbidden",
|
|
171
|
+
{
|
|
172
|
+
status:
|
|
173
|
+
403,
|
|
174
|
+
}
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return authenticated;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function createAuthGuard<
|
|
182
|
+
TUser extends AuthUser = AuthUser,
|
|
183
|
+
TData extends object = Record<string, never>
|
|
184
|
+
>(
|
|
185
|
+
options: RequireAuthOptions = {}
|
|
186
|
+
): AuthGuardFunction<TUser, TData> {
|
|
187
|
+
return () =>
|
|
188
|
+
requireAuth<
|
|
189
|
+
TUser,
|
|
190
|
+
TData
|
|
191
|
+
>(
|
|
192
|
+
options
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function createRoleGuard<
|
|
197
|
+
TUser extends AuthUser = AuthUser,
|
|
198
|
+
TData extends object = Record<string, never>
|
|
199
|
+
>(
|
|
200
|
+
requiredRole: RequiredRole,
|
|
201
|
+
options: RequireRoleOptions = {}
|
|
202
|
+
): AuthGuardFunction<TUser, TData> {
|
|
203
|
+
return () =>
|
|
204
|
+
requireRole<
|
|
205
|
+
TUser,
|
|
206
|
+
TData
|
|
207
|
+
>(
|
|
208
|
+
requiredRole,
|
|
209
|
+
options
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function getGuardAuth<
|
|
214
|
+
TUser extends AuthUser = AuthUser,
|
|
215
|
+
TData extends object = Record<string, never>
|
|
216
|
+
>(
|
|
217
|
+
guardData:
|
|
218
|
+
Readonly<{
|
|
219
|
+
auth?: unknown;
|
|
220
|
+
}>
|
|
221
|
+
): AuthSession<TUser, TData> | null {
|
|
222
|
+
const value =
|
|
223
|
+
guardData.auth;
|
|
224
|
+
|
|
225
|
+
if (
|
|
226
|
+
!value ||
|
|
227
|
+
typeof value !==
|
|
228
|
+
"object" ||
|
|
229
|
+
Array.isArray(
|
|
230
|
+
value
|
|
231
|
+
)
|
|
232
|
+
) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const session =
|
|
237
|
+
value as Partial<
|
|
238
|
+
AuthSession<
|
|
239
|
+
TUser,
|
|
240
|
+
TData
|
|
241
|
+
>
|
|
242
|
+
>;
|
|
243
|
+
|
|
244
|
+
if (
|
|
245
|
+
typeof session.sid !==
|
|
246
|
+
"string" ||
|
|
247
|
+
session.sid.trim().length ===
|
|
248
|
+
0 ||
|
|
249
|
+
typeof session.iat !==
|
|
250
|
+
"number" ||
|
|
251
|
+
!Number.isFinite(
|
|
252
|
+
session.iat
|
|
253
|
+
) ||
|
|
254
|
+
typeof session.exp !==
|
|
255
|
+
"number" ||
|
|
256
|
+
!Number.isFinite(
|
|
257
|
+
session.exp
|
|
258
|
+
) ||
|
|
259
|
+
!isGuardAuthUser(
|
|
260
|
+
session.user
|
|
261
|
+
)
|
|
262
|
+
) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return value as
|
|
267
|
+
AuthSession<
|
|
268
|
+
TUser,
|
|
269
|
+
TData
|
|
270
|
+
>;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function normalizeRequiredRoles(
|
|
274
|
+
value: RequiredRole
|
|
275
|
+
): string[] {
|
|
276
|
+
const roles =
|
|
277
|
+
typeof value ===
|
|
278
|
+
"string"
|
|
279
|
+
? [
|
|
280
|
+
value,
|
|
281
|
+
]
|
|
282
|
+
: [
|
|
283
|
+
...value,
|
|
284
|
+
];
|
|
285
|
+
const normalized =
|
|
286
|
+
roles.map(
|
|
287
|
+
(role) =>
|
|
288
|
+
role.trim()
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
if (
|
|
292
|
+
normalized.length ===
|
|
293
|
+
0 ||
|
|
294
|
+
normalized.some(
|
|
295
|
+
(role) =>
|
|
296
|
+
role.length ===
|
|
297
|
+
0
|
|
298
|
+
)
|
|
299
|
+
) {
|
|
300
|
+
throw new TypeError(
|
|
301
|
+
"BCP Auth Guard: required roles must contain non-empty strings."
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return Array.from(
|
|
306
|
+
new Set(
|
|
307
|
+
normalized
|
|
308
|
+
)
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function readAssignedRoles(
|
|
313
|
+
user: AuthUser,
|
|
314
|
+
roleField: string
|
|
315
|
+
): string[] {
|
|
316
|
+
const field =
|
|
317
|
+
roleField.trim();
|
|
318
|
+
|
|
319
|
+
if (!field) {
|
|
320
|
+
throw new TypeError(
|
|
321
|
+
"BCP Auth Guard: roleField must be a non-empty string."
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const value =
|
|
326
|
+
(
|
|
327
|
+
user as
|
|
328
|
+
unknown as
|
|
329
|
+
Record<string, unknown>
|
|
330
|
+
)[field];
|
|
331
|
+
|
|
332
|
+
if (
|
|
333
|
+
typeof value ===
|
|
334
|
+
"string"
|
|
335
|
+
) {
|
|
336
|
+
const role =
|
|
337
|
+
value.trim();
|
|
338
|
+
|
|
339
|
+
return role
|
|
340
|
+
? [
|
|
341
|
+
role,
|
|
342
|
+
]
|
|
343
|
+
: [];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (
|
|
347
|
+
Array.isArray(
|
|
348
|
+
value
|
|
349
|
+
)
|
|
350
|
+
) {
|
|
351
|
+
return value
|
|
352
|
+
.filter(
|
|
353
|
+
(
|
|
354
|
+
role
|
|
355
|
+
): role is string =>
|
|
356
|
+
typeof role ===
|
|
357
|
+
"string"
|
|
358
|
+
)
|
|
359
|
+
.map(
|
|
360
|
+
(role) =>
|
|
361
|
+
role.trim()
|
|
362
|
+
)
|
|
363
|
+
.filter(
|
|
364
|
+
Boolean
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return [];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function isGuardAuthUser(
|
|
372
|
+
value: unknown
|
|
373
|
+
): value is AuthUser {
|
|
374
|
+
if (
|
|
375
|
+
!value ||
|
|
376
|
+
typeof value !==
|
|
377
|
+
"object" ||
|
|
378
|
+
Array.isArray(
|
|
379
|
+
value
|
|
380
|
+
)
|
|
381
|
+
) {
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const id =
|
|
386
|
+
(
|
|
387
|
+
value as
|
|
388
|
+
Record<string, unknown>
|
|
389
|
+
).id;
|
|
390
|
+
|
|
391
|
+
return (
|
|
392
|
+
typeof id ===
|
|
393
|
+
"string" &&
|
|
394
|
+
id.trim().length >
|
|
395
|
+
0
|
|
396
|
+
) || (
|
|
397
|
+
typeof id ===
|
|
398
|
+
"number" &&
|
|
399
|
+
Number.isFinite(
|
|
400
|
+
id
|
|
401
|
+
)
|
|
402
|
+
);
|
|
403
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from "node:url";
|
|
6
6
|
|
|
7
7
|
import type {
|
|
8
|
+
MiddlewareHandler,
|
|
8
9
|
MiddlewareModule,
|
|
9
10
|
} from "./middleware.js";
|
|
10
11
|
|
|
@@ -93,16 +94,17 @@ export function assertMiddlewareModule(
|
|
|
93
94
|
module: MiddlewareModule,
|
|
94
95
|
filePath = "middleware"
|
|
95
96
|
): void {
|
|
96
|
-
const
|
|
97
|
+
const value =
|
|
97
98
|
module.middleware ??
|
|
98
99
|
module.default;
|
|
99
100
|
|
|
100
101
|
if (
|
|
101
|
-
|
|
102
|
-
|
|
102
|
+
!isMiddlewareHandlerValue(
|
|
103
|
+
value
|
|
104
|
+
)
|
|
103
105
|
) {
|
|
104
106
|
throw new Error(
|
|
105
|
-
`BCP Framework: ${filePath} must export a middleware
|
|
107
|
+
`BCP Framework: ${filePath} must export a middleware function, a middleware function array, or a default equivalent.`
|
|
106
108
|
);
|
|
107
109
|
}
|
|
108
110
|
|
|
@@ -125,12 +127,12 @@ export function assertMiddlewareModule(
|
|
|
125
127
|
];
|
|
126
128
|
|
|
127
129
|
for (
|
|
128
|
-
const
|
|
130
|
+
const item
|
|
129
131
|
of values
|
|
130
132
|
) {
|
|
131
133
|
if (
|
|
132
|
-
typeof
|
|
133
|
-
!(
|
|
134
|
+
typeof item !== "string" &&
|
|
135
|
+
!(item instanceof RegExp)
|
|
134
136
|
) {
|
|
135
137
|
throw new Error(
|
|
136
138
|
`BCP Framework: middleware matcher in ${filePath} must be a string, RegExp, or an array of them.`
|
|
@@ -138,3 +140,28 @@ export function assertMiddlewareModule(
|
|
|
138
140
|
}
|
|
139
141
|
}
|
|
140
142
|
}
|
|
143
|
+
|
|
144
|
+
function isMiddlewareHandlerValue(
|
|
145
|
+
value: unknown
|
|
146
|
+
): value is
|
|
147
|
+
| MiddlewareHandler
|
|
148
|
+
| readonly MiddlewareHandler[] {
|
|
149
|
+
if (
|
|
150
|
+
typeof value ===
|
|
151
|
+
"function"
|
|
152
|
+
) {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
Array.isArray(
|
|
158
|
+
value
|
|
159
|
+
) &&
|
|
160
|
+
value.length > 0 &&
|
|
161
|
+
value.every(
|
|
162
|
+
(handler) =>
|
|
163
|
+
typeof handler ===
|
|
164
|
+
"function"
|
|
165
|
+
)
|
|
166
|
+
);
|
|
167
|
+
}
|