@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.
@@ -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.4",
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
- string |
23
- URL |
24
- null;
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
- TUser,
190
- TData
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
- Readonly<{
219
- auth?: unknown;
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
- "string" ||
247
- session.sid.trim().length ===
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
- "string" &&
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
  )