@chidchanun/bcp 0.1.15 → 0.1.16

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,201 @@
1
+ # Authentication
2
+
3
+ BCP Framework 0.1.16 adds a server-only authentication layer through `bcp/auth`.
4
+ It builds on the signed JWT cookie/session primitives from `bcp/server` and provides a higher-level API for application authentication.
5
+
6
+ ## Environment
7
+
8
+ Set a session secret with at least 32 bytes:
9
+
10
+ ```env
11
+ BCP_SESSION_SECRET=replace-with-a-long-random-secret-at-least-32-bytes
12
+ ```
13
+
14
+ The default cookie is `bcp_session`. It is HttpOnly, uses `SameSite=Lax`, has a 12-hour lifetime, and is Secure automatically when `NODE_ENV=production`.
15
+
16
+ ## Basic usage
17
+
18
+ ```ts
19
+ import {
20
+ auth,
21
+ login,
22
+ logout,
23
+ } from "bcp/auth";
24
+
25
+ export async function POST() {
26
+ await login({
27
+ id: 42,
28
+ email: "user@example.com",
29
+ role: "admin",
30
+ });
31
+
32
+ return Response.json({
33
+ ok: true,
34
+ });
35
+ }
36
+ ```
37
+
38
+ Read the current authenticated session:
39
+
40
+ ```ts
41
+ import {
42
+ auth,
43
+ } from "bcp/auth";
44
+
45
+ const session =
46
+ await auth<{
47
+ id: number;
48
+ email: string;
49
+ role: string;
50
+ }>();
51
+
52
+ if (!session) {
53
+ // Not authenticated.
54
+ }
55
+
56
+ console.log(
57
+ session?.user.id
58
+ );
59
+ ```
60
+
61
+ `getSession()` is an alias for `auth()` when that naming is clearer in application code.
62
+
63
+ ## Typed auth factory
64
+
65
+ For application-wide types and cookie settings, create a typed auth instance:
66
+
67
+ ```ts
68
+ import {
69
+ createAuth,
70
+ } from "bcp/auth";
71
+
72
+ interface AppUser {
73
+ id: number;
74
+ email: string;
75
+ role: "admin" | "user";
76
+ }
77
+
78
+ interface AppSessionData {
79
+ tenantId: string;
80
+ }
81
+
82
+ export const appAuth =
83
+ createAuth<
84
+ AppUser,
85
+ AppSessionData
86
+ >({
87
+ cookieName:
88
+ "app_session",
89
+ expiresIn:
90
+ 60 * 60 * 8,
91
+ issuer:
92
+ "my-app",
93
+ audience:
94
+ "web",
95
+ });
96
+ ```
97
+
98
+ Login with typed session data:
99
+
100
+ ```ts
101
+ await appAuth.login(
102
+ {
103
+ id: 42,
104
+ email: "user@example.com",
105
+ role: "admin",
106
+ },
107
+ {
108
+ data: {
109
+ tenantId:
110
+ "tenant-1",
111
+ },
112
+ }
113
+ );
114
+ ```
115
+
116
+ Read it later:
117
+
118
+ ```ts
119
+ const session =
120
+ await appAuth.auth();
121
+
122
+ console.log(
123
+ session?.user.email
124
+ );
125
+ console.log(
126
+ session?.data?.tenantId
127
+ );
128
+ ```
129
+
130
+ ## Logout
131
+
132
+ ```ts
133
+ await logout();
134
+ ```
135
+
136
+ Or with a factory:
137
+
138
+ ```ts
139
+ await appAuth.logout();
140
+ ```
141
+
142
+ Logout expires the configured authentication cookie.
143
+
144
+ ## Session rotation
145
+
146
+ Each auth login receives a unique `sid` (session identifier). `rotateSession()` keeps the current user and session data but issues a new `sid`, JWT, expiry window, and cookie.
147
+
148
+ ```ts
149
+ const rotated =
150
+ await appAuth.rotateSession();
151
+
152
+ if (rotated) {
153
+ console.log(
154
+ rotated.sid
155
+ );
156
+ }
157
+ ```
158
+
159
+ Rotation returns `null` when no valid auth session exists.
160
+
161
+ A useful policy is to rotate after a security-sensitive event such as a privilege change or successful re-authentication.
162
+
163
+ ## Session shape
164
+
165
+ An authenticated session contains the user, optional application session data, and signed JWT claims:
166
+
167
+ ```ts
168
+ {
169
+ sid: string;
170
+ user: AppUser;
171
+ data?: AppSessionData;
172
+ iat: number;
173
+ exp: number;
174
+ iss?: string;
175
+ aud?: string | string[];
176
+ }
177
+ ```
178
+
179
+ ## Security notes
180
+
181
+ - `bcp/auth` is server-only and must not be imported into pages or client islands.
182
+ - Never put passwords, password hashes, API secrets, access keys, or other sensitive credentials in the auth user/session payload. JWT cookie payloads are signed, not encrypted.
183
+ - Keep `BCP_SESSION_SECRET` out of source control and use a strong random value of at least 32 bytes.
184
+ - Authentication verifies identity/session state. Application authorization such as roles and permissions belongs in route guards or server actions.
185
+ - Use HTTPS in production so Secure cookies are transmitted only over encrypted connections.
186
+
187
+ ## create-bcp-app
188
+
189
+ When `JWT Cookie` authentication is selected, generated applications use `createAuth()` internally and expose helpers from `lib/auth.ts`:
190
+
191
+ ```ts
192
+ import {
193
+ auth,
194
+ getSession,
195
+ login,
196
+ logout,
197
+ rotateSession,
198
+ } from "@/lib/auth";
199
+ ```
200
+
201
+ The generated `authenticateCredentials()` intentionally returns `null` until the application implements its own user lookup and password verification strategy.
@@ -0,0 +1,53 @@
1
+ # BCP Framework 0.1.16
2
+
3
+ ## Authentication Core
4
+
5
+ BCP 0.1.16 introduces the server-only `bcp/auth` entrypoint.
6
+
7
+ ### New APIs
8
+
9
+ - `auth()` reads and validates the current authentication session.
10
+ - `getSession()` is an auth-focused alias for `auth()`.
11
+ - `login(user, options)` creates an authenticated JWT cookie session.
12
+ - `logout(options)` expires the authentication cookie.
13
+ - `rotateSession(options)` preserves the authenticated user/session data while issuing a new session identifier and token.
14
+ - `createAuth<User, SessionData>(defaults)` creates a typed application auth instance with shared cookie/token configuration.
15
+
16
+ ### Session identity
17
+
18
+ Every login now receives a random UUID `sid`. Session rotation always issues a new `sid`, so rotation produces a distinct signed token even if it happens within the same second.
19
+
20
+ ### Security
21
+
22
+ - `bcp/auth` is exported as server-only in the package manifest.
23
+ - The client-boundary validator blocks `bcp/auth` from page/client graphs.
24
+ - The same validator now explicitly blocks direct `bcp/database` imports from client graphs as well.
25
+ - Existing JWT signing, expiration, issuer/audience validation, HttpOnly cookies, production Secure defaults, and minimum 32-byte session secrets remain provided by the underlying session runtime.
26
+
27
+ ### create-bcp-app
28
+
29
+ The JWT Cookie preset now uses the framework auth core through `createAuth()` while keeping the existing starter helper names and API routes compatible.
30
+
31
+ Generated `lib/auth.ts` also exposes:
32
+
33
+ ```ts
34
+ export const auth = frameworkAuth.auth;
35
+ export const getSession = frameworkAuth.getSession;
36
+ export const login = frameworkAuth.login;
37
+ export const logout = frameworkAuth.logout;
38
+ export const rotateSession = frameworkAuth.rotateSession;
39
+ ```
40
+
41
+ `authenticateCredentials()` still returns `null` until the application implements its own user lookup and password verification.
42
+
43
+ ### Tests
44
+
45
+ 0.1.16 adds regression coverage for:
46
+
47
+ - typed login/read/logout lifecycle;
48
+ - typed `createAuth()` defaults;
49
+ - session rotation and changing `sid` values;
50
+ - invalid auth payload rejection;
51
+ - published `bcp/auth` export wiring;
52
+ - client-boundary blocking for auth/database;
53
+ - JWT Cookie scaffold integration with the framework auth core.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chidchanun/bcp",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
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",
@@ -48,6 +48,11 @@
48
48
  "browser": "./packages/client/src/server-only.browser.mjs",
49
49
  "default": "./packages/client/src/database.ts"
50
50
  },
51
+ "./auth": {
52
+ "types": "./packages/client/src/auth.ts",
53
+ "browser": "./packages/client/src/server-only.browser.mjs",
54
+ "default": "./packages/client/src/auth.ts"
55
+ },
51
56
  "./server": {
52
57
  "types": "./packages/client/src/server.ts",
53
58
  "browser": "./packages/client/src/server-only.browser.mjs",
@@ -22,7 +22,13 @@ const MODULE_EXTENSIONS = [
22
22
  ".cjs",
23
23
  ] as const;
24
24
 
25
- const SERVER_ONLY_IMPORTS = new Set(["bcp/server","bcp/server-only"])
25
+ const SERVER_ONLY_IMPORTS =
26
+ new Set([
27
+ "bcp/server",
28
+ "bcp/server-only",
29
+ "bcp/database",
30
+ "bcp/auth",
31
+ ]);
26
32
 
27
33
  export function validateClientBoundaries(
28
34
  routes: Route[],
@@ -129,13 +135,17 @@ function visitClientModule(
129
135
  source
130
136
  )
131
137
  ) {
132
- if (SERVER_ONLY_IMPORTS.has(specifier)){
138
+ if (
139
+ SERVER_ONLY_IMPORTS.has(
140
+ specifier
141
+ )
142
+ ) {
133
143
  throw new Error(
134
144
  `BCP Framework: ${formatApplicationPath(
135
145
  rootDirectory,
136
146
  filePath
137
147
  )} imports ${specifier} but is reachable from the client bundle for ${routeName}. Move the server dependency behind an API route.`
138
- )
148
+ );
139
149
  }
140
150
 
141
151
  const dependency =
@@ -0,0 +1,14 @@
1
+ export {
2
+ auth,
3
+ createAuth,
4
+ getSession,
5
+ login,
6
+ logout,
7
+ rotateSession,
8
+
9
+ type AuthApi,
10
+ type AuthLoginOptions,
11
+ type AuthOptions,
12
+ type AuthSession,
13
+ type AuthUser,
14
+ } from "../../server/src/auth.js";
@@ -0,0 +1,376 @@
1
+ import {
2
+ randomUUID,
3
+ } from "node:crypto";
4
+
5
+ import {
6
+ createSession,
7
+ destroySession,
8
+ getSession as readSession,
9
+ type SessionClaims,
10
+ type SessionCookieOptions,
11
+ } from "./session.js";
12
+
13
+ export interface AuthUser {
14
+ id: string | number;
15
+ }
16
+
17
+ export interface AuthSession<
18
+ TUser extends AuthUser = AuthUser,
19
+ TData extends object = Record<string, never>
20
+ > extends SessionClaims {
21
+ sid: string;
22
+ user: TUser;
23
+ data?: TData;
24
+ }
25
+
26
+ export interface AuthOptions
27
+ extends SessionCookieOptions {}
28
+
29
+ export interface AuthLoginOptions<
30
+ TData extends object = Record<string, never>
31
+ > extends AuthOptions {
32
+ data?: TData;
33
+ }
34
+
35
+ export interface AuthApi<
36
+ TUser extends AuthUser = AuthUser,
37
+ TData extends object = Record<string, never>
38
+ > {
39
+ auth(
40
+ options?: AuthOptions
41
+ ): Promise<AuthSession<TUser, TData> | null>;
42
+ getSession(
43
+ options?: AuthOptions
44
+ ): Promise<AuthSession<TUser, TData> | null>;
45
+ login(
46
+ user: TUser,
47
+ options?: AuthLoginOptions<TData>
48
+ ): Promise<AuthSession<TUser, TData>>;
49
+ logout(
50
+ options?: AuthOptions
51
+ ): Promise<void>;
52
+ rotateSession(
53
+ options?: AuthOptions
54
+ ): Promise<AuthSession<TUser, TData> | null>;
55
+ }
56
+
57
+ interface AuthSessionPayload<
58
+ TUser extends AuthUser,
59
+ TData extends object
60
+ > {
61
+ sid: string;
62
+ user: TUser;
63
+ data?: TData;
64
+ }
65
+
66
+ export async function auth<
67
+ TUser extends AuthUser = AuthUser,
68
+ TData extends object = Record<string, never>
69
+ >(
70
+ options: AuthOptions = {}
71
+ ): Promise<AuthSession<TUser, TData> | null> {
72
+ const session =
73
+ await readSession<
74
+ AuthSessionPayload<
75
+ TUser,
76
+ TData
77
+ >
78
+ >(
79
+ options
80
+ );
81
+
82
+ if (!session) {
83
+ return null;
84
+ }
85
+
86
+ if (
87
+ typeof session.sid !==
88
+ "string" ||
89
+ session.sid.trim().length ===
90
+ 0 ||
91
+ !isAuthUser(
92
+ session.user
93
+ )
94
+ ) {
95
+ return null;
96
+ }
97
+
98
+ if (
99
+ session.data !== undefined &&
100
+ !isPlainObject(
101
+ session.data
102
+ )
103
+ ) {
104
+ return null;
105
+ }
106
+
107
+ return session as
108
+ AuthSession<
109
+ TUser,
110
+ TData
111
+ >;
112
+ }
113
+
114
+ export async function getSession<
115
+ TUser extends AuthUser = AuthUser,
116
+ TData extends object = Record<string, never>
117
+ >(
118
+ options: AuthOptions = {}
119
+ ): Promise<AuthSession<TUser, TData> | null> {
120
+ return auth<
121
+ TUser,
122
+ TData
123
+ >(
124
+ options
125
+ );
126
+ }
127
+
128
+ export async function login<
129
+ TUser extends AuthUser,
130
+ TData extends object = Record<string, never>
131
+ >(
132
+ user: TUser,
133
+ options:
134
+ AuthLoginOptions<TData> = {}
135
+ ): Promise<AuthSession<TUser, TData>> {
136
+ assertAuthUser(
137
+ user
138
+ );
139
+
140
+ if (
141
+ options.data !== undefined &&
142
+ !isPlainObject(
143
+ options.data
144
+ )
145
+ ) {
146
+ throw new TypeError(
147
+ "BCP Auth: session data must be a plain object."
148
+ );
149
+ }
150
+
151
+ const {
152
+ data,
153
+ ...sessionOptions
154
+ } = options;
155
+ const payload:
156
+ AuthSessionPayload<
157
+ TUser,
158
+ TData
159
+ > = {
160
+ sid:
161
+ randomUUID(),
162
+ user,
163
+ ...(data === undefined
164
+ ? {}
165
+ : {
166
+ data,
167
+ }),
168
+ };
169
+
170
+ await createSession(
171
+ payload,
172
+ sessionOptions
173
+ );
174
+
175
+ const session =
176
+ await auth<
177
+ TUser,
178
+ TData
179
+ >(
180
+ sessionOptions
181
+ );
182
+
183
+ if (!session) {
184
+ throw new Error(
185
+ "BCP Auth: login created a session but it could not be read back."
186
+ );
187
+ }
188
+
189
+ return session;
190
+ }
191
+
192
+ export async function logout(
193
+ options: AuthOptions = {}
194
+ ): Promise<void> {
195
+ await destroySession(
196
+ options
197
+ );
198
+ }
199
+
200
+ export async function rotateSession<
201
+ TUser extends AuthUser = AuthUser,
202
+ TData extends object = Record<string, never>
203
+ >(
204
+ options: AuthOptions = {}
205
+ ): Promise<AuthSession<TUser, TData> | null> {
206
+ const current =
207
+ await auth<
208
+ TUser,
209
+ TData
210
+ >(
211
+ options
212
+ );
213
+
214
+ if (!current) {
215
+ return null;
216
+ }
217
+
218
+ return login<
219
+ TUser,
220
+ TData
221
+ >(
222
+ current.user,
223
+ {
224
+ ...options,
225
+ issuer:
226
+ options.issuer ??
227
+ current.iss,
228
+ audience:
229
+ options.audience ??
230
+ current.aud,
231
+ ...(current.data === undefined
232
+ ? {}
233
+ : {
234
+ data:
235
+ current.data,
236
+ }),
237
+ }
238
+ );
239
+ }
240
+
241
+ export function createAuth<
242
+ TUser extends AuthUser = AuthUser,
243
+ TData extends object = Record<string, never>
244
+ >(
245
+ defaults: AuthOptions = {}
246
+ ): AuthApi<TUser, TData> {
247
+ return {
248
+ auth: (
249
+ options = {}
250
+ ) =>
251
+ auth<
252
+ TUser,
253
+ TData
254
+ >({
255
+ ...defaults,
256
+ ...options,
257
+ }),
258
+ getSession: (
259
+ options = {}
260
+ ) =>
261
+ auth<
262
+ TUser,
263
+ TData
264
+ >({
265
+ ...defaults,
266
+ ...options,
267
+ }),
268
+ login: (
269
+ user,
270
+ options = {}
271
+ ) => {
272
+ const {
273
+ data,
274
+ ...sessionOptions
275
+ } = options;
276
+
277
+ return login<
278
+ TUser,
279
+ TData
280
+ >(
281
+ user,
282
+ {
283
+ ...defaults,
284
+ ...sessionOptions,
285
+ ...(data === undefined
286
+ ? {}
287
+ : {
288
+ data,
289
+ }),
290
+ }
291
+ );
292
+ },
293
+ logout: (
294
+ options = {}
295
+ ) =>
296
+ logout({
297
+ ...defaults,
298
+ ...options,
299
+ }),
300
+ rotateSession: (
301
+ options = {}
302
+ ) =>
303
+ rotateSession<
304
+ TUser,
305
+ TData
306
+ >({
307
+ ...defaults,
308
+ ...options,
309
+ }),
310
+ };
311
+ }
312
+
313
+ function assertAuthUser(
314
+ value: unknown
315
+ ): asserts value is AuthUser {
316
+ if (
317
+ !isAuthUser(
318
+ value
319
+ )
320
+ ) {
321
+ throw new TypeError(
322
+ "BCP Auth: user must be a plain object with a non-empty string or finite numeric id."
323
+ );
324
+ }
325
+ }
326
+
327
+ function isAuthUser(
328
+ value: unknown
329
+ ): value is AuthUser {
330
+ if (
331
+ !isPlainObject(
332
+ value
333
+ )
334
+ ) {
335
+ return false;
336
+ }
337
+
338
+ const id =
339
+ value.id;
340
+
341
+ return (
342
+ typeof id === "string" &&
343
+ id.trim().length > 0
344
+ ) || (
345
+ typeof id === "number" &&
346
+ Number.isFinite(
347
+ id
348
+ )
349
+ );
350
+ }
351
+
352
+ function isPlainObject(
353
+ value: unknown
354
+ ): value is Record<string, unknown> {
355
+ if (
356
+ value === null ||
357
+ typeof value !== "object" ||
358
+ Array.isArray(
359
+ value
360
+ )
361
+ ) {
362
+ return false;
363
+ }
364
+
365
+ const prototype =
366
+ Object.getPrototypeOf(
367
+ value
368
+ );
369
+
370
+ return (
371
+ prototype ===
372
+ Object.prototype ||
373
+ prototype ===
374
+ null
375
+ );
376
+ }