@chidchanun/bcp 0.2.4 → 0.2.5
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/README.md +76 -46
- package/docs/README.md +77 -126
- package/docs/api-manifest.json +3 -2
- package/docs/api-reference.md +22 -2
- package/docs/auth-route-guards.md +72 -37
- package/docs/auth-session-store.md +204 -0
- package/docs/authentication.md +128 -44
- package/docs/docs-web-manifest.json +5 -3
- package/docs/platform-manifest.json +12 -4
- package/docs/releases/0.2.5.md +152 -0
- package/package.json +1 -1
- package/packages/client/src/auth.ts +16 -0
- package/packages/server/src/auth-guard.ts +95 -82
- package/packages/server/src/auth-session-store.ts +318 -0
- package/packages/server/src/auth.ts +443 -50
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# BCP Framework 0.2.5
|
|
2
|
+
|
|
3
|
+
## Authentication Platform v2
|
|
4
|
+
|
|
5
|
+
`0.2.5` expands `bcp/auth` from signed JWT-cookie authentication into an optional revocable session platform while preserving the stateless mode used by existing applications.
|
|
6
|
+
|
|
7
|
+
## Highlights
|
|
8
|
+
|
|
9
|
+
### Server-side auth session store contract
|
|
10
|
+
|
|
11
|
+
New public types:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import type {
|
|
15
|
+
AuthSessionStore,
|
|
16
|
+
AuthSessionStoreRecord,
|
|
17
|
+
} from "bcp/auth";
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The contract provides:
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
set
|
|
24
|
+
get
|
|
25
|
+
touch
|
|
26
|
+
revoke
|
|
27
|
+
revokeUser
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Applications can back this contract with Redis, SQL, or another shared server-side store.
|
|
31
|
+
|
|
32
|
+
### Built-in memory store
|
|
33
|
+
|
|
34
|
+
Development and tests can use:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import {
|
|
38
|
+
createMemoryAuthSessionStore,
|
|
39
|
+
} from "bcp/auth";
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The memory adapter is process-local and intentionally not presented as a distributed production session database.
|
|
43
|
+
|
|
44
|
+
### Revocable sessions
|
|
45
|
+
|
|
46
|
+
When an auth store is configured, a valid signed JWT must also have an active server-side `sid` record.
|
|
47
|
+
|
|
48
|
+
BCP can therefore invalidate a session before the cookie's JWT expiry.
|
|
49
|
+
|
|
50
|
+
New APIs include:
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
logoutAll()
|
|
54
|
+
revokeSession()
|
|
55
|
+
revokeUserSessions()
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Normal `logout()` also revokes the current `sid` when a store is enabled.
|
|
59
|
+
|
|
60
|
+
### Idle timeout
|
|
61
|
+
|
|
62
|
+
`AuthOptions` now accepts:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
idleTimeout: number
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The value is measured in seconds and requires a session store.
|
|
69
|
+
|
|
70
|
+
Successful authentication updates the store's `lastSeenAt`. Sessions that exceed the inactivity window are revoked and rejected.
|
|
71
|
+
|
|
72
|
+
BCP intentionally rejects `idleTimeout` without a store instead of silently providing a false sense of server-side inactivity enforcement.
|
|
73
|
+
|
|
74
|
+
### Session rotation
|
|
75
|
+
|
|
76
|
+
`rotateSession()` continues issuing a fresh `sid`, JWT, expiry, and cookie.
|
|
77
|
+
|
|
78
|
+
With a session store enabled, the previous `sid` is revoked so old cookies no longer pass the server-side session check.
|
|
79
|
+
|
|
80
|
+
### Guest route guards
|
|
81
|
+
|
|
82
|
+
New APIs:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
requireGuest()
|
|
86
|
+
createGuestGuard()
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
These support login, registration, and similar pages that should continue for anonymous users but redirect users who are already authenticated.
|
|
90
|
+
|
|
91
|
+
Default authenticated-user behavior is a `303` redirect. `redirectTo: null` returns `409 Already authenticated`.
|
|
92
|
+
|
|
93
|
+
## Backward compatibility
|
|
94
|
+
|
|
95
|
+
The default remains stateless signed JWT-cookie authentication:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import {
|
|
99
|
+
auth,
|
|
100
|
+
login,
|
|
101
|
+
logout,
|
|
102
|
+
} from "bcp/auth";
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Applications do not need to configure a session store unless they need centralized revocation or idle-timeout behavior.
|
|
106
|
+
|
|
107
|
+
No existing public application entrypoint is intentionally removed in this release.
|
|
108
|
+
|
|
109
|
+
## Security guidance
|
|
110
|
+
|
|
111
|
+
- JWT payloads remain signed rather than encrypted.
|
|
112
|
+
- Do not place passwords, password hashes, tokens, API keys, or credentials in auth payload data.
|
|
113
|
+
- Keep `BCP_SESSION_SECRET` outside source control and at least 32 bytes long.
|
|
114
|
+
- Use a shared durable session-store implementation for production applications that run multiple Node.js processes or containers.
|
|
115
|
+
- The built-in memory store is suitable for local development/tests and process-local prototypes.
|
|
116
|
+
|
|
117
|
+
## Documentation
|
|
118
|
+
|
|
119
|
+
Updated guides:
|
|
120
|
+
|
|
121
|
+
```text
|
|
122
|
+
docs/authentication.md
|
|
123
|
+
docs/auth-session-store.md
|
|
124
|
+
docs/auth-route-guards.md
|
|
125
|
+
docs/api-reference.md
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Validation
|
|
129
|
+
|
|
130
|
+
`0.2.5` adds unit and package smoke coverage for:
|
|
131
|
+
|
|
132
|
+
```text
|
|
133
|
+
session-store registration and lookup
|
|
134
|
+
session revocation
|
|
135
|
+
session rotation with revocation
|
|
136
|
+
logout-all
|
|
137
|
+
idle timeout
|
|
138
|
+
guest route guards
|
|
139
|
+
bcp/auth publish surface
|
|
140
|
+
Authentication Platform v2 package contents
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Before tagging or publishing, run the complete RC sequence:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
npm run typecheck
|
|
147
|
+
npm run test:unit
|
|
148
|
+
npm run test:integration
|
|
149
|
+
npm run test:e2e
|
|
150
|
+
npm run test:package
|
|
151
|
+
npm run rc:check
|
|
152
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chidchanun/bcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "BCP Framework - a React full-stack framework with file-based routing, SSR, APIs, middleware, islands, caching and standalone production builds.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -4,6 +4,9 @@ export {
|
|
|
4
4
|
getSession,
|
|
5
5
|
login,
|
|
6
6
|
logout,
|
|
7
|
+
logoutAll,
|
|
8
|
+
revokeSession,
|
|
9
|
+
revokeUserSessions,
|
|
7
10
|
rotateSession,
|
|
8
11
|
|
|
9
12
|
type AuthApi,
|
|
@@ -13,17 +16,30 @@ export {
|
|
|
13
16
|
type AuthUser,
|
|
14
17
|
} from "../../server/src/auth.js";
|
|
15
18
|
|
|
19
|
+
export {
|
|
20
|
+
createMemoryAuthSessionStore,
|
|
21
|
+
|
|
22
|
+
type AuthSessionStore,
|
|
23
|
+
type AuthSessionStoreRecord,
|
|
24
|
+
type MemoryAuthSessionStore,
|
|
25
|
+
} from "../../server/src/auth-session-store.js";
|
|
26
|
+
|
|
16
27
|
export {
|
|
17
28
|
createAuthGuard,
|
|
29
|
+
createGuestGuard,
|
|
18
30
|
createRoleGuard,
|
|
19
31
|
getGuardAuth,
|
|
20
32
|
requireAuth,
|
|
33
|
+
requireGuest,
|
|
21
34
|
requireRole,
|
|
22
35
|
|
|
23
36
|
type AuthGuardData,
|
|
24
37
|
type AuthGuardFunction,
|
|
25
38
|
type AuthGuardResult,
|
|
39
|
+
type GuestGuardFunction,
|
|
40
|
+
type GuestGuardResult,
|
|
26
41
|
type RequireAuthOptions,
|
|
42
|
+
type RequireGuestOptions,
|
|
27
43
|
type RequireRoleOptions,
|
|
28
44
|
type RequiredRole,
|
|
29
45
|
} from "../../server/src/auth-guard.js";
|
|
@@ -18,10 +18,13 @@ export interface AuthGuardData<
|
|
|
18
18
|
|
|
19
19
|
export interface RequireAuthOptions
|
|
20
20
|
extends AuthOptions {
|
|
21
|
-
redirectTo?:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
redirectTo?: string | URL | null;
|
|
22
|
+
redirectStatus?: RedirectStatus;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RequireGuestOptions
|
|
26
|
+
extends AuthOptions {
|
|
27
|
+
redirectTo?: string | URL | null;
|
|
25
28
|
redirectStatus?: RedirectStatus;
|
|
26
29
|
}
|
|
27
30
|
|
|
@@ -29,10 +32,7 @@ export interface RequireRoleOptions
|
|
|
29
32
|
extends RequireAuthOptions {
|
|
30
33
|
roleField?: string;
|
|
31
34
|
match?: "any" | "all";
|
|
32
|
-
forbiddenRedirectTo?:
|
|
33
|
-
string |
|
|
34
|
-
URL |
|
|
35
|
-
null;
|
|
35
|
+
forbiddenRedirectTo?: string | URL | null;
|
|
36
36
|
forbiddenRedirectStatus?: RedirectStatus;
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -47,16 +47,20 @@ export type AuthGuardResult<
|
|
|
47
47
|
| AuthGuardData<TUser, TData>
|
|
48
48
|
| Response;
|
|
49
49
|
|
|
50
|
+
export type GuestGuardResult =
|
|
51
|
+
| Record<string, never>
|
|
52
|
+
| Response;
|
|
53
|
+
|
|
50
54
|
export type AuthGuardFunction<
|
|
51
55
|
TUser extends AuthUser = AuthUser,
|
|
52
56
|
TData extends object = Record<string, never>
|
|
53
57
|
> = () => Promise<
|
|
54
|
-
AuthGuardResult<
|
|
55
|
-
TUser,
|
|
56
|
-
TData
|
|
57
|
-
>
|
|
58
|
+
AuthGuardResult<TUser, TData>
|
|
58
59
|
>;
|
|
59
60
|
|
|
61
|
+
export type GuestGuardFunction =
|
|
62
|
+
() => Promise<GuestGuardResult>;
|
|
63
|
+
|
|
60
64
|
export async function requireAuth<
|
|
61
65
|
TUser extends AuthUser = AuthUser,
|
|
62
66
|
TData extends object = Record<string, never>
|
|
@@ -69,23 +73,18 @@ export async function requireAuth<
|
|
|
69
73
|
...authOptions
|
|
70
74
|
} = options;
|
|
71
75
|
const session =
|
|
72
|
-
await auth<
|
|
73
|
-
TUser,
|
|
74
|
-
TData
|
|
75
|
-
>(
|
|
76
|
+
await auth<TUser, TData>(
|
|
76
77
|
authOptions
|
|
77
78
|
);
|
|
78
79
|
|
|
79
80
|
if (!session) {
|
|
80
81
|
if (
|
|
81
|
-
redirectTo ===
|
|
82
|
-
null
|
|
82
|
+
redirectTo === null
|
|
83
83
|
) {
|
|
84
84
|
return new Response(
|
|
85
85
|
"Unauthorized",
|
|
86
86
|
{
|
|
87
|
-
status:
|
|
88
|
-
401,
|
|
87
|
+
status: 401,
|
|
89
88
|
}
|
|
90
89
|
);
|
|
91
90
|
}
|
|
@@ -97,11 +96,47 @@ export async function requireAuth<
|
|
|
97
96
|
}
|
|
98
97
|
|
|
99
98
|
return {
|
|
100
|
-
auth:
|
|
101
|
-
session,
|
|
99
|
+
auth: session,
|
|
102
100
|
};
|
|
103
101
|
}
|
|
104
102
|
|
|
103
|
+
export async function requireGuest<
|
|
104
|
+
TUser extends AuthUser = AuthUser,
|
|
105
|
+
TData extends object = Record<string, never>
|
|
106
|
+
>(
|
|
107
|
+
options: RequireGuestOptions = {}
|
|
108
|
+
): Promise<GuestGuardResult> {
|
|
109
|
+
const {
|
|
110
|
+
redirectTo = "/",
|
|
111
|
+
redirectStatus = 303,
|
|
112
|
+
...authOptions
|
|
113
|
+
} = options;
|
|
114
|
+
const session =
|
|
115
|
+
await auth<TUser, TData>(
|
|
116
|
+
authOptions
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
if (!session) {
|
|
120
|
+
return {};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (
|
|
124
|
+
redirectTo === null
|
|
125
|
+
) {
|
|
126
|
+
return new Response(
|
|
127
|
+
"Already authenticated",
|
|
128
|
+
{
|
|
129
|
+
status: 409,
|
|
130
|
+
}
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return redirect(
|
|
135
|
+
redirectTo,
|
|
136
|
+
redirectStatus
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
105
140
|
export async function requireRole<
|
|
106
141
|
TUser extends AuthUser = AuthUser,
|
|
107
142
|
TData extends object = Record<string, never>
|
|
@@ -121,16 +156,12 @@ export async function requireRole<
|
|
|
121
156
|
...authOptions
|
|
122
157
|
} = options;
|
|
123
158
|
const authenticated =
|
|
124
|
-
await requireAuth<
|
|
125
|
-
TUser,
|
|
126
|
-
TData
|
|
127
|
-
>(
|
|
159
|
+
await requireAuth<TUser, TData>(
|
|
128
160
|
authOptions
|
|
129
161
|
);
|
|
130
162
|
|
|
131
163
|
if (
|
|
132
|
-
authenticated instanceof
|
|
133
|
-
Response
|
|
164
|
+
authenticated instanceof Response
|
|
134
165
|
) {
|
|
135
166
|
return authenticated;
|
|
136
167
|
}
|
|
@@ -157,8 +188,7 @@ export async function requireRole<
|
|
|
157
188
|
|
|
158
189
|
if (!allowed) {
|
|
159
190
|
if (
|
|
160
|
-
forbiddenRedirectTo !==
|
|
161
|
-
null
|
|
191
|
+
forbiddenRedirectTo !== null
|
|
162
192
|
) {
|
|
163
193
|
return redirect(
|
|
164
194
|
forbiddenRedirectTo,
|
|
@@ -169,8 +199,7 @@ export async function requireRole<
|
|
|
169
199
|
return new Response(
|
|
170
200
|
"Forbidden",
|
|
171
201
|
{
|
|
172
|
-
status:
|
|
173
|
-
403,
|
|
202
|
+
status: 403,
|
|
174
203
|
}
|
|
175
204
|
);
|
|
176
205
|
}
|
|
@@ -185,10 +214,19 @@ export function createAuthGuard<
|
|
|
185
214
|
options: RequireAuthOptions = {}
|
|
186
215
|
): AuthGuardFunction<TUser, TData> {
|
|
187
216
|
return () =>
|
|
188
|
-
requireAuth<
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
217
|
+
requireAuth<TUser, TData>(
|
|
218
|
+
options
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function createGuestGuard<
|
|
223
|
+
TUser extends AuthUser = AuthUser,
|
|
224
|
+
TData extends object = Record<string, never>
|
|
225
|
+
>(
|
|
226
|
+
options: RequireGuestOptions = {}
|
|
227
|
+
): GuestGuardFunction {
|
|
228
|
+
return () =>
|
|
229
|
+
requireGuest<TUser, TData>(
|
|
192
230
|
options
|
|
193
231
|
);
|
|
194
232
|
}
|
|
@@ -201,10 +239,7 @@ export function createRoleGuard<
|
|
|
201
239
|
options: RequireRoleOptions = {}
|
|
202
240
|
): AuthGuardFunction<TUser, TData> {
|
|
203
241
|
return () =>
|
|
204
|
-
requireRole<
|
|
205
|
-
TUser,
|
|
206
|
-
TData
|
|
207
|
-
>(
|
|
242
|
+
requireRole<TUser, TData>(
|
|
208
243
|
requiredRole,
|
|
209
244
|
options
|
|
210
245
|
);
|
|
@@ -214,18 +249,16 @@ export function getGuardAuth<
|
|
|
214
249
|
TUser extends AuthUser = AuthUser,
|
|
215
250
|
TData extends object = Record<string, never>
|
|
216
251
|
>(
|
|
217
|
-
guardData:
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}>
|
|
252
|
+
guardData: Readonly<{
|
|
253
|
+
auth?: unknown;
|
|
254
|
+
}>
|
|
221
255
|
): AuthSession<TUser, TData> | null {
|
|
222
256
|
const value =
|
|
223
257
|
guardData.auth;
|
|
224
258
|
|
|
225
259
|
if (
|
|
226
260
|
!value ||
|
|
227
|
-
typeof value !==
|
|
228
|
-
"object" ||
|
|
261
|
+
typeof value !== "object" ||
|
|
229
262
|
Array.isArray(
|
|
230
263
|
value
|
|
231
264
|
)
|
|
@@ -235,24 +268,17 @@ export function getGuardAuth<
|
|
|
235
268
|
|
|
236
269
|
const session =
|
|
237
270
|
value as Partial<
|
|
238
|
-
AuthSession<
|
|
239
|
-
TUser,
|
|
240
|
-
TData
|
|
241
|
-
>
|
|
271
|
+
AuthSession<TUser, TData>
|
|
242
272
|
>;
|
|
243
273
|
|
|
244
274
|
if (
|
|
245
|
-
typeof session.sid !==
|
|
246
|
-
|
|
247
|
-
session.
|
|
248
|
-
0 ||
|
|
249
|
-
typeof session.iat !==
|
|
250
|
-
"number" ||
|
|
275
|
+
typeof session.sid !== "string" ||
|
|
276
|
+
session.sid.trim().length === 0 ||
|
|
277
|
+
typeof session.iat !== "number" ||
|
|
251
278
|
!Number.isFinite(
|
|
252
279
|
session.iat
|
|
253
280
|
) ||
|
|
254
|
-
typeof session.exp !==
|
|
255
|
-
"number" ||
|
|
281
|
+
typeof session.exp !== "number" ||
|
|
256
282
|
!Number.isFinite(
|
|
257
283
|
session.exp
|
|
258
284
|
) ||
|
|
@@ -264,18 +290,14 @@ export function getGuardAuth<
|
|
|
264
290
|
}
|
|
265
291
|
|
|
266
292
|
return value as
|
|
267
|
-
AuthSession<
|
|
268
|
-
TUser,
|
|
269
|
-
TData
|
|
270
|
-
>;
|
|
293
|
+
AuthSession<TUser, TData>;
|
|
271
294
|
}
|
|
272
295
|
|
|
273
296
|
function normalizeRequiredRoles(
|
|
274
297
|
value: RequiredRole
|
|
275
298
|
): string[] {
|
|
276
299
|
const roles =
|
|
277
|
-
typeof value ===
|
|
278
|
-
"string"
|
|
300
|
+
typeof value === "string"
|
|
279
301
|
? [
|
|
280
302
|
value,
|
|
281
303
|
]
|
|
@@ -289,12 +311,10 @@ function normalizeRequiredRoles(
|
|
|
289
311
|
);
|
|
290
312
|
|
|
291
313
|
if (
|
|
292
|
-
normalized.length ===
|
|
293
|
-
0 ||
|
|
314
|
+
normalized.length === 0 ||
|
|
294
315
|
normalized.some(
|
|
295
316
|
(role) =>
|
|
296
|
-
role.length ===
|
|
297
|
-
0
|
|
317
|
+
role.length === 0
|
|
298
318
|
)
|
|
299
319
|
) {
|
|
300
320
|
throw new TypeError(
|
|
@@ -324,14 +344,12 @@ function readAssignedRoles(
|
|
|
324
344
|
|
|
325
345
|
const value =
|
|
326
346
|
(
|
|
327
|
-
user as
|
|
328
|
-
unknown as
|
|
347
|
+
user as unknown as
|
|
329
348
|
Record<string, unknown>
|
|
330
349
|
)[field];
|
|
331
350
|
|
|
332
351
|
if (
|
|
333
|
-
typeof value ===
|
|
334
|
-
"string"
|
|
352
|
+
typeof value === "string"
|
|
335
353
|
) {
|
|
336
354
|
const role =
|
|
337
355
|
value.trim();
|
|
@@ -353,8 +371,7 @@ function readAssignedRoles(
|
|
|
353
371
|
(
|
|
354
372
|
role
|
|
355
373
|
): role is string =>
|
|
356
|
-
typeof role ===
|
|
357
|
-
"string"
|
|
374
|
+
typeof role === "string"
|
|
358
375
|
)
|
|
359
376
|
.map(
|
|
360
377
|
(role) =>
|
|
@@ -373,8 +390,7 @@ function isGuardAuthUser(
|
|
|
373
390
|
): value is AuthUser {
|
|
374
391
|
if (
|
|
375
392
|
!value ||
|
|
376
|
-
typeof value !==
|
|
377
|
-
"object" ||
|
|
393
|
+
typeof value !== "object" ||
|
|
378
394
|
Array.isArray(
|
|
379
395
|
value
|
|
380
396
|
)
|
|
@@ -389,13 +405,10 @@ function isGuardAuthUser(
|
|
|
389
405
|
).id;
|
|
390
406
|
|
|
391
407
|
return (
|
|
392
|
-
typeof id ===
|
|
393
|
-
|
|
394
|
-
id.trim().length >
|
|
395
|
-
0
|
|
408
|
+
typeof id === "string" &&
|
|
409
|
+
id.trim().length > 0
|
|
396
410
|
) || (
|
|
397
|
-
typeof id ===
|
|
398
|
-
"number" &&
|
|
411
|
+
typeof id === "number" &&
|
|
399
412
|
Number.isFinite(
|
|
400
413
|
id
|
|
401
414
|
)
|