@chidchanun/bcp 0.1.7 → 0.1.8

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/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  All notable framework changes are tracked here before release.
4
4
 
5
+ ## 0.1.8 - JWT cookie sessions and auth preset
6
+
7
+ ### Authentication
8
+
9
+ - Added `createSessionToken()` and `verifySessionToken()` to `bcp/server` for HS256 JWT session tokens.
10
+ - Added `createSession()` to sign a token and store it in an HttpOnly cookie with secure production defaults.
11
+ - Added `getSession()` to read and verify the active session cookie and return typed claims or `null` for invalid, expired or tampered tokens.
12
+ - Added `destroySession()` to expire the configured session cookie.
13
+ - Session tokens use `BCP_SESSION_SECRET` by default and reject secrets shorter than 32 bytes.
14
+ - Token verification validates HS256 signatures with constant-time comparison plus expiration, optional issuer and optional audience checks.
15
+ - JWT cookie defaults are `bcp_session`, 12-hour lifetime, `HttpOnly`, `SameSite=Lax`, `Path=/`, and `Secure` when `NODE_ENV=production`.
16
+ - Added low-level and high-level session option types through the existing server-only `bcp/server` entrypoint.
17
+
18
+ ### create-bcp-app
19
+
20
+ - Added interactive Authentication selection with `None` and `JWT Cookie` presets.
21
+ - Added the non-interactive `--auth <preset>` option with `none` and `jwt-cookie` values.
22
+ - JWT Cookie projects generate `lib/auth.ts` plus `/api/auth/login`, `/api/auth/logout` and `/api/auth/me` route handlers.
23
+ - Generated auth projects add `BCP_SESSION_SECRET=` to `.env.example`.
24
+ - Generated `authenticateCredentials(email, password)` returns `null` until the application connects its own database lookup and password-hash verification, keeping the starter secure by default.
25
+ - MySQL projects now generate a reusable `db` pool configured through `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD` and `DB_NAME`, with development pool reuse through `globalThis`.
26
+
27
+ ### Reliability
28
+
29
+ - Added unit coverage for token round-trips, signature tampering, wrong secrets, issuer/audience mismatches, expiration, missing/short secrets and cookie lifecycle behavior.
30
+ - Added development integration coverage for login-style session creation, authenticated reads and logout cookie expiration.
31
+ - Added standalone production E2E coverage for the same JWT cookie session lifecycle after production bundling.
32
+ - Added create-app regression coverage for JWT Cookie scaffolding, secure credential-verification defaults and authentication-disabled projects.
33
+ - Added a basic application session API fixture and package-smoke checks for the session runtime and public helper surface.
34
+
5
35
  ## 0.1.7 - Server request context and cookies
6
36
 
7
37
  ### Server APIs
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  BCP Framework is a React full-stack framework with file-based routing, SSR, client navigation, API routes, middleware, metadata, client islands, cache/revalidation, security defaults and standalone production builds.
4
4
 
5
- > Current development version: `0.1.7`. BCP is still pre-1.0 and validates each release candidate before the manual npm publish step.
5
+ > Current development version: `0.1.8`. BCP is still pre-1.0 and validates each release candidate before the manual npm publish step.
6
6
 
7
7
  ## Quick start
8
8
 
@@ -196,6 +196,113 @@ export async function POST() {
196
196
 
197
197
  `redirect()` defaults to status `307`; supported statuses are `301`, `302`, `303`, `307` and `308`. Use `cookieStore.delete("session")` to expire a cookie. See [Server Request APIs](docs/server-request-apis.md) for proxy trust, URL handling, response helpers, request isolation, cookie options and boundary details.
198
198
 
199
+ ## JWT cookie sessions
200
+
201
+ BCP 0.1.8 adds HS256 JWT cookie sessions directly to `bcp/server`. Configure a server-only secret of at least 32 bytes:
202
+
203
+ ```env
204
+ BCP_SESSION_SECRET=replace-this-with-a-long-random-secret-at-least-32-bytes
205
+ ```
206
+
207
+ Login-style APIs can create an HttpOnly session cookie without manually signing or serializing the JWT:
208
+
209
+ ```ts
210
+ import {
211
+ createSession,
212
+ json,
213
+ } from "bcp/server";
214
+
215
+ export async function POST() {
216
+ await createSession(
217
+ {
218
+ userId: 42,
219
+ email: "user@example.com",
220
+ role: "admin",
221
+ },
222
+ {
223
+ issuer: "my-app",
224
+ audience: "my-app-users",
225
+ }
226
+ );
227
+
228
+ return json({
229
+ success: true,
230
+ });
231
+ }
232
+ ```
233
+
234
+ Protected API routes can verify and read the session:
235
+
236
+ ```ts
237
+ import {
238
+ getSession,
239
+ json,
240
+ } from "bcp/server";
241
+
242
+ export async function GET() {
243
+ const session =
244
+ await getSession<{
245
+ userId: number;
246
+ email: string;
247
+ role: string;
248
+ }>({
249
+ issuer: "my-app",
250
+ audience: "my-app-users",
251
+ });
252
+
253
+ if (!session) {
254
+ return json(
255
+ {
256
+ error: "Unauthorized",
257
+ },
258
+ {
259
+ status: 401,
260
+ }
261
+ );
262
+ }
263
+
264
+ return json({
265
+ userId:
266
+ session.userId,
267
+ email:
268
+ session.email,
269
+ role:
270
+ session.role,
271
+ });
272
+ }
273
+ ```
274
+
275
+ Use `destroySession()` to expire the cookie. Low-level `createSessionToken()` and `verifySessionToken()` helpers are also available when an application needs to manage token storage itself. JWT payloads are signed, not encrypted, so sensitive secrets must not be stored inside them. See [JWT Cookie Sessions](docs/session-auth.md) for options and the security model.
276
+
277
+ ## create-bcp-app auth preset
278
+
279
+ BCP 0.1.8 can scaffold the JWT cookie foundation automatically:
280
+
281
+ ```bash
282
+ npx create-bcp-app my-app --database mysql --auth jwt-cookie
283
+ ```
284
+
285
+ Interactive setup offers `None` and `JWT Cookie`. Selecting JWT Cookie creates:
286
+
287
+ ```text
288
+ lib/auth.ts
289
+ app/api/auth/login/route.ts
290
+ app/api/auth/logout/route.ts
291
+ app/api/auth/me/route.ts
292
+ ```
293
+
294
+ and adds:
295
+
296
+ ```env
297
+ BCP_SESSION_SECRET=
298
+ ```
299
+
300
+ to `.env.example`.
301
+
302
+ The generated `authenticateCredentials(email, password)` returns `null` by default. Applications must connect it to their own database lookup and password-hash verification before login can succeed. This is intentional so a newly generated project does not trust user identity supplied directly by the browser.
303
+
304
+ The generated MySQL preset uses a reusable `db` pool and separate `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD` and `DB_NAME` settings.
305
+
199
306
  ## Metadata
200
307
 
201
308
  ```ts
@@ -338,6 +445,7 @@ No real npm publish command is run automatically by the repository.
338
445
  - [Getting Started](docs/getting-started.md)
339
446
  - [Application Modules](docs/application-modules.md)
340
447
  - [Server Request APIs](docs/server-request-apis.md)
448
+ - [JWT Cookie Sessions](docs/session-auth.md)
341
449
  - [Routing](docs/routing.md)
342
450
  - [Configuration](docs/configuration.md)
343
451
  - [Caching](docs/caching.md)
@@ -0,0 +1,186 @@
1
+ # JWT Cookie Sessions
2
+
3
+ BCP Framework provides server-only JWT cookie session helpers through `bcp/server`.
4
+
5
+ The current session implementation uses HS256 with Node.js `crypto`, requires a secret of at least 32 bytes, stores the token in an HttpOnly cookie by default and validates expiry before returning a session.
6
+
7
+ ## Environment
8
+
9
+ Set a strong session secret in the server environment:
10
+
11
+ ```env
12
+ BCP_SESSION_SECRET=replace-this-with-a-long-random-secret-at-least-32-bytes
13
+ ```
14
+
15
+ Do not expose this value to browser code or prefix it as a public environment variable.
16
+
17
+ ## Create a session
18
+
19
+ `createSession()` signs a JWT and stores it in a cookie. The default cookie name is `bcp_session` and the default lifetime is 12 hours.
20
+
21
+ ```ts
22
+ import {
23
+ createSession,
24
+ json,
25
+ } from "bcp/server";
26
+
27
+ export async function POST() {
28
+ await createSession(
29
+ {
30
+ userId: 42,
31
+ email: "user@example.com",
32
+ role: "admin",
33
+ },
34
+ {
35
+ issuer: "my-app",
36
+ audience: "my-app-users",
37
+ }
38
+ );
39
+
40
+ return json({
41
+ success: true,
42
+ });
43
+ }
44
+ ```
45
+
46
+ By default BCP sets:
47
+
48
+ - `HttpOnly`
49
+ - `SameSite=Lax`
50
+ - `Path=/`
51
+ - `Secure` when `NODE_ENV=production`
52
+ - `Max-Age` equal to the token lifetime
53
+
54
+ Cookie and token behavior can be customized:
55
+
56
+ ```ts
57
+ await createSession(
58
+ {
59
+ userId: 42,
60
+ },
61
+ {
62
+ cookieName: "access_token",
63
+ expiresIn: 60 * 60,
64
+ issuer: "my-app",
65
+ audience: [
66
+ "web",
67
+ "api",
68
+ ],
69
+ sameSite: "strict",
70
+ secure: true,
71
+ path: "/",
72
+ }
73
+ );
74
+ ```
75
+
76
+ ## Read a session
77
+
78
+ `getSession()` reads the cookie and verifies the JWT signature, expiration, issuer and audience when those options are provided.
79
+
80
+ ```ts
81
+ import {
82
+ getSession,
83
+ json,
84
+ } from "bcp/server";
85
+
86
+ export async function GET() {
87
+ const session =
88
+ await getSession<{
89
+ userId: number;
90
+ email: string;
91
+ role: string;
92
+ }>({
93
+ issuer: "my-app",
94
+ audience: "my-app-users",
95
+ });
96
+
97
+ if (!session) {
98
+ return json(
99
+ {
100
+ error: "Unauthorized",
101
+ },
102
+ {
103
+ status: 401,
104
+ }
105
+ );
106
+ }
107
+
108
+ return json({
109
+ userId:
110
+ session.userId,
111
+ email:
112
+ session.email,
113
+ role:
114
+ session.role,
115
+ });
116
+ }
117
+ ```
118
+
119
+ Invalid, expired or tampered session tokens return `null` instead of throwing.
120
+
121
+ ## Destroy a session
122
+
123
+ `destroySession()` expires the session cookie:
124
+
125
+ ```ts
126
+ import {
127
+ destroySession,
128
+ json,
129
+ } from "bcp/server";
130
+
131
+ export async function POST() {
132
+ await destroySession();
133
+
134
+ return json({
135
+ success: true,
136
+ });
137
+ }
138
+ ```
139
+
140
+ When a custom cookie name, path or domain is used while creating the session, use the same values when destroying it.
141
+
142
+ ## Low-level token APIs
143
+
144
+ BCP also exposes token-only helpers when an application needs to manage storage itself:
145
+
146
+ ```ts
147
+ import {
148
+ createSessionToken,
149
+ verifySessionToken,
150
+ } from "bcp/server";
151
+
152
+ const token =
153
+ await createSessionToken(
154
+ {
155
+ userId: 42,
156
+ },
157
+ {
158
+ expiresIn: 3600,
159
+ issuer: "my-app",
160
+ }
161
+ );
162
+
163
+ const claims =
164
+ await verifySessionToken<{
165
+ userId: number;
166
+ }>(
167
+ token,
168
+ {
169
+ issuer: "my-app",
170
+ }
171
+ );
172
+ ```
173
+
174
+ `createSessionToken()` and `verifySessionToken()` use `BCP_SESSION_SECRET` unless an explicit `secret` option is supplied.
175
+
176
+ ## Security model
177
+
178
+ BCP session tokens use HS256 and reject secrets shorter than 32 bytes. Verification validates the signature with a constant-time comparison and rejects malformed, expired or unsupported JWTs.
179
+
180
+ JWT payloads are signed, not encrypted. Do not place passwords, API secrets or other confidential values in the session payload.
181
+
182
+ Session revocation is not automatic for stateless JWTs. Applications that require immediate revocation should store a session identifier in the token and validate it against a server-side session table or another revocation store.
183
+
184
+ ## API route boundary
185
+
186
+ These helpers are exported from `bcp/server` and remain server-only. In the current 0.1.x page/client pipeline, call them from API routes rather than modules reachable from the browser bundle.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chidchanun/bcp",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
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",
@@ -21,3 +21,16 @@ export {
21
21
 
22
22
  type RedirectStatus,
23
23
  } from "../../server/src/server-response.js";
24
+
25
+ export {
26
+ createSession,
27
+ createSessionToken,
28
+ destroySession,
29
+ getSession,
30
+ verifySessionToken,
31
+
32
+ type SessionClaims,
33
+ type SessionCookieOptions,
34
+ type SessionPayload,
35
+ type SessionTokenOptions,
36
+ } from "../../server/src/session.js";
@@ -0,0 +1,694 @@
1
+ import {
2
+ createHmac,
3
+ timingSafeEqual,
4
+ } from "node:crypto";
5
+
6
+ import {
7
+ cookies,
8
+ type CookieSameSite,
9
+ } from "./request-context.js";
10
+
11
+ const DEFAULT_COOKIE_NAME =
12
+ "bcp_session";
13
+ const DEFAULT_EXPIRES_IN =
14
+ 60 * 60 * 12;
15
+ const MAX_TOKEN_LENGTH =
16
+ 16 * 1024;
17
+ const MINIMUM_SECRET_BYTES =
18
+ 32;
19
+
20
+ export interface SessionPayload {
21
+ [key: string]: unknown;
22
+ }
23
+
24
+ export interface SessionClaims
25
+ extends SessionPayload {
26
+ iat: number;
27
+ exp: number;
28
+ iss?: string;
29
+ aud?:
30
+ string |
31
+ string[];
32
+ }
33
+
34
+ export interface SessionTokenOptions {
35
+ secret?: string;
36
+ expiresIn?: number;
37
+ issuer?: string;
38
+ audience?:
39
+ string |
40
+ string[];
41
+ }
42
+
43
+ export interface SessionCookieOptions
44
+ extends SessionTokenOptions {
45
+ cookieName?: string;
46
+ path?: string;
47
+ domain?: string;
48
+ httpOnly?: boolean;
49
+ secure?: boolean;
50
+ sameSite?:
51
+ CookieSameSite;
52
+ }
53
+
54
+ export async function createSessionToken<
55
+ T extends object
56
+ >(
57
+ payload: T,
58
+ options:
59
+ SessionTokenOptions = {}
60
+ ): Promise<string> {
61
+ assertPayload(
62
+ payload
63
+ );
64
+
65
+ const secret =
66
+ resolveSessionSecret(
67
+ options.secret
68
+ );
69
+ const expiresIn =
70
+ resolveExpiresIn(
71
+ options.expiresIn
72
+ );
73
+ const issuer =
74
+ resolveOptionalIssuer(
75
+ options.issuer
76
+ );
77
+ const audience =
78
+ options.audience ===
79
+ undefined
80
+ ? undefined
81
+ : normalizeAudience(
82
+ options.audience
83
+ );
84
+ const now =
85
+ Math.floor(
86
+ Date.now() /
87
+ 1000
88
+ );
89
+
90
+ const header = {
91
+ alg:
92
+ "HS256",
93
+ typ:
94
+ "JWT",
95
+ };
96
+
97
+ const claims:
98
+ SessionClaims = {
99
+ ...payload,
100
+ iat:
101
+ now,
102
+ exp:
103
+ now +
104
+ expiresIn,
105
+ };
106
+
107
+ if (
108
+ issuer !==
109
+ undefined
110
+ ) {
111
+ claims.iss =
112
+ issuer;
113
+ }
114
+
115
+ if (
116
+ audience !==
117
+ undefined
118
+ ) {
119
+ claims.aud =
120
+ audience;
121
+ }
122
+
123
+ const encodedHeader =
124
+ encodeJson(
125
+ header
126
+ );
127
+ const encodedPayload =
128
+ encodeJson(
129
+ claims
130
+ );
131
+ const signingInput =
132
+ `${encodedHeader}.${encodedPayload}`;
133
+ const signature =
134
+ signHs256(
135
+ signingInput,
136
+ secret
137
+ );
138
+
139
+ return `${signingInput}.${signature}`;
140
+ }
141
+
142
+ export async function verifySessionToken<
143
+ T extends object =
144
+ SessionPayload
145
+ >(
146
+ token: string,
147
+ options:
148
+ SessionTokenOptions = {}
149
+ ): Promise<(
150
+ T & SessionClaims
151
+ ) | null> {
152
+ const secret =
153
+ resolveSessionSecret(
154
+ options.secret
155
+ );
156
+ const issuer =
157
+ resolveOptionalIssuer(
158
+ options.issuer
159
+ );
160
+ const audience =
161
+ options.audience ===
162
+ undefined
163
+ ? undefined
164
+ : normalizeAudience(
165
+ options.audience
166
+ );
167
+
168
+ try {
169
+ if (
170
+ typeof token !==
171
+ "string" ||
172
+ token.length === 0 ||
173
+ token.length >
174
+ MAX_TOKEN_LENGTH
175
+ ) {
176
+ return null;
177
+ }
178
+
179
+ const parts =
180
+ token.split(".");
181
+
182
+ if (
183
+ parts.length !==
184
+ 3
185
+ ) {
186
+ return null;
187
+ }
188
+
189
+ const [
190
+ encodedHeader,
191
+ encodedPayload,
192
+ signature,
193
+ ] = parts;
194
+
195
+ const header =
196
+ decodeJson(
197
+ encodedHeader
198
+ ) as Record<
199
+ string,
200
+ unknown
201
+ >;
202
+
203
+ if (
204
+ header.alg !==
205
+ "HS256" ||
206
+ (
207
+ header.typ !==
208
+ undefined &&
209
+ header.typ !==
210
+ "JWT"
211
+ )
212
+ ) {
213
+ return null;
214
+ }
215
+
216
+ const signingInput =
217
+ `${encodedHeader}.${encodedPayload}`;
218
+ const expectedSignature =
219
+ signHs256(
220
+ signingInput,
221
+ secret
222
+ );
223
+
224
+ if (
225
+ !safeEqual(
226
+ signature,
227
+ expectedSignature
228
+ )
229
+ ) {
230
+ return null;
231
+ }
232
+
233
+ const claims =
234
+ decodeJson(
235
+ encodedPayload
236
+ ) as Record<
237
+ string,
238
+ unknown
239
+ >;
240
+ const now =
241
+ Math.floor(
242
+ Date.now() /
243
+ 1000
244
+ );
245
+
246
+ if (
247
+ typeof claims.iat !==
248
+ "number" ||
249
+ !Number.isFinite(
250
+ claims.iat
251
+ ) ||
252
+ typeof claims.exp !==
253
+ "number" ||
254
+ !Number.isFinite(
255
+ claims.exp
256
+ ) ||
257
+ claims.exp <=
258
+ now
259
+ ) {
260
+ return null;
261
+ }
262
+
263
+ if (
264
+ claims.nbf !==
265
+ undefined
266
+ ) {
267
+ if (
268
+ typeof claims.nbf !==
269
+ "number" ||
270
+ !Number.isFinite(
271
+ claims.nbf
272
+ ) ||
273
+ claims.nbf >
274
+ now
275
+ ) {
276
+ return null;
277
+ }
278
+ }
279
+
280
+ if (
281
+ issuer !==
282
+ undefined &&
283
+ claims.iss !==
284
+ issuer
285
+ ) {
286
+ return null;
287
+ }
288
+
289
+ if (
290
+ audience !==
291
+ undefined &&
292
+ !audienceMatches(
293
+ claims.aud,
294
+ audience
295
+ )
296
+ ) {
297
+ return null;
298
+ }
299
+
300
+ return claims as
301
+ T & SessionClaims;
302
+ } catch {
303
+ return null;
304
+ }
305
+ }
306
+
307
+ export async function createSession<
308
+ T extends object
309
+ >(
310
+ payload: T,
311
+ options:
312
+ SessionCookieOptions = {}
313
+ ): Promise<string> {
314
+ const expiresIn =
315
+ resolveExpiresIn(
316
+ options.expiresIn
317
+ );
318
+ const token =
319
+ await createSessionToken(
320
+ payload,
321
+ {
322
+ secret:
323
+ options.secret,
324
+ expiresIn,
325
+ issuer:
326
+ options.issuer,
327
+ audience:
328
+ options.audience,
329
+ }
330
+ );
331
+ const cookieStore =
332
+ await cookies();
333
+
334
+ cookieStore.set(
335
+ resolveCookieName(
336
+ options.cookieName
337
+ ),
338
+ token,
339
+ {
340
+ httpOnly:
341
+ options.httpOnly ??
342
+ true,
343
+ secure:
344
+ options.secure ??
345
+ process.env.NODE_ENV ===
346
+ "production",
347
+ sameSite:
348
+ options.sameSite ??
349
+ "lax",
350
+ path:
351
+ options.path ??
352
+ "/",
353
+ domain:
354
+ options.domain,
355
+ maxAge:
356
+ expiresIn,
357
+ }
358
+ );
359
+
360
+ return token;
361
+ }
362
+
363
+ export async function getSession<
364
+ T extends object =
365
+ SessionPayload
366
+ >(
367
+ options:
368
+ SessionCookieOptions = {}
369
+ ): Promise<(
370
+ T & SessionClaims
371
+ ) | null> {
372
+ const cookieStore =
373
+ await cookies();
374
+ const token =
375
+ cookieStore.get(
376
+ resolveCookieName(
377
+ options.cookieName
378
+ )
379
+ )?.value;
380
+
381
+ if (!token) {
382
+ return null;
383
+ }
384
+
385
+ return verifySessionToken<
386
+ T
387
+ >(
388
+ token,
389
+ {
390
+ secret:
391
+ options.secret,
392
+ issuer:
393
+ options.issuer,
394
+ audience:
395
+ options.audience,
396
+ }
397
+ );
398
+ }
399
+
400
+ export async function destroySession(
401
+ options:
402
+ SessionCookieOptions = {}
403
+ ): Promise<void> {
404
+ const cookieStore =
405
+ await cookies();
406
+
407
+ cookieStore.delete(
408
+ resolveCookieName(
409
+ options.cookieName
410
+ ),
411
+ {
412
+ path:
413
+ options.path ??
414
+ "/",
415
+ domain:
416
+ options.domain,
417
+ }
418
+ );
419
+ }
420
+
421
+ function resolveSessionSecret(
422
+ explicitSecret:
423
+ string | undefined
424
+ ): string {
425
+ const secret =
426
+ explicitSecret ??
427
+ process.env.BCP_SESSION_SECRET;
428
+
429
+ if (!secret) {
430
+ throw new Error(
431
+ "BCP Framework: BCP_SESSION_SECRET is required for session tokens."
432
+ );
433
+ }
434
+
435
+ if (
436
+ Buffer.byteLength(
437
+ secret,
438
+ "utf8"
439
+ ) <
440
+ MINIMUM_SECRET_BYTES
441
+ ) {
442
+ throw new Error(
443
+ `BCP Framework: session secret must be at least ${MINIMUM_SECRET_BYTES} bytes.`
444
+ );
445
+ }
446
+
447
+ return secret;
448
+ }
449
+
450
+ function resolveExpiresIn(
451
+ value:
452
+ number | undefined
453
+ ): number {
454
+ const expiresIn =
455
+ value ??
456
+ DEFAULT_EXPIRES_IN;
457
+
458
+ if (
459
+ !Number.isFinite(
460
+ expiresIn
461
+ ) ||
462
+ expiresIn <= 0
463
+ ) {
464
+ throw new Error(
465
+ "BCP Framework: session expiresIn must be a positive finite number of seconds."
466
+ );
467
+ }
468
+
469
+ return Math.floor(
470
+ expiresIn
471
+ );
472
+ }
473
+
474
+ function resolveCookieName(
475
+ value:
476
+ string | undefined
477
+ ): string {
478
+ const cookieName =
479
+ value ??
480
+ DEFAULT_COOKIE_NAME;
481
+
482
+ assertNonEmptyString(
483
+ cookieName,
484
+ "cookieName"
485
+ );
486
+
487
+ return cookieName;
488
+ }
489
+
490
+ function resolveOptionalIssuer(
491
+ value:
492
+ string | undefined
493
+ ): string | undefined {
494
+ if (
495
+ value ===
496
+ undefined
497
+ ) {
498
+ return undefined;
499
+ }
500
+
501
+ assertNonEmptyString(
502
+ value,
503
+ "issuer"
504
+ );
505
+
506
+ return value;
507
+ }
508
+
509
+ function assertPayload(
510
+ payload:
511
+ object
512
+ ): void {
513
+ if (
514
+ payload === null ||
515
+ typeof payload !==
516
+ "object" ||
517
+ Array.isArray(
518
+ payload
519
+ )
520
+ ) {
521
+ throw new Error(
522
+ "BCP Framework: session payload must be a plain object."
523
+ );
524
+ }
525
+ }
526
+
527
+ function assertNonEmptyString(
528
+ value: string,
529
+ label: string
530
+ ): void {
531
+ if (
532
+ typeof value !==
533
+ "string" ||
534
+ value.trim().length ===
535
+ 0
536
+ ) {
537
+ throw new Error(
538
+ `BCP Framework: session ${label} must be a non-empty string.`
539
+ );
540
+ }
541
+ }
542
+
543
+ function normalizeAudience(
544
+ value:
545
+ string |
546
+ string[]
547
+ ): string | string[] {
548
+ if (
549
+ typeof value ===
550
+ "string"
551
+ ) {
552
+ assertNonEmptyString(
553
+ value,
554
+ "audience"
555
+ );
556
+ return value;
557
+ }
558
+
559
+ if (
560
+ !Array.isArray(
561
+ value
562
+ ) ||
563
+ value.length ===
564
+ 0
565
+ ) {
566
+ throw new Error(
567
+ "BCP Framework: session audience must contain at least one value."
568
+ );
569
+ }
570
+
571
+ for (
572
+ const audience
573
+ of value
574
+ ) {
575
+ assertNonEmptyString(
576
+ audience,
577
+ "audience"
578
+ );
579
+ }
580
+
581
+ return [
582
+ ...value,
583
+ ];
584
+ }
585
+
586
+ function audienceMatches(
587
+ claim: unknown,
588
+ expected:
589
+ string |
590
+ string[]
591
+ ): boolean {
592
+ const expectedValues =
593
+ typeof expected ===
594
+ "string"
595
+ ? [
596
+ expected,
597
+ ]
598
+ : expected;
599
+ const claimValues =
600
+ typeof claim ===
601
+ "string"
602
+ ? [
603
+ claim,
604
+ ]
605
+ : Array.isArray(
606
+ claim
607
+ )
608
+ ? claim.filter(
609
+ (
610
+ value
611
+ ): value is string =>
612
+ typeof value ===
613
+ "string"
614
+ )
615
+ : [];
616
+
617
+ return expectedValues.some(
618
+ (value) =>
619
+ claimValues.includes(
620
+ value
621
+ )
622
+ );
623
+ }
624
+
625
+ function encodeJson(
626
+ value: unknown
627
+ ): string {
628
+ return Buffer.from(
629
+ JSON.stringify(
630
+ value
631
+ ),
632
+ "utf8"
633
+ ).toString(
634
+ "base64url"
635
+ );
636
+ }
637
+
638
+ function decodeJson(
639
+ value: string
640
+ ): unknown {
641
+ const decoded =
642
+ Buffer.from(
643
+ value,
644
+ "base64url"
645
+ ).toString(
646
+ "utf8"
647
+ );
648
+
649
+ return JSON.parse(
650
+ decoded
651
+ );
652
+ }
653
+
654
+ function signHs256(
655
+ signingInput: string,
656
+ secret: string
657
+ ): string {
658
+ return createHmac(
659
+ "sha256",
660
+ secret
661
+ )
662
+ .update(
663
+ signingInput,
664
+ "utf8"
665
+ )
666
+ .digest(
667
+ "base64url"
668
+ );
669
+ }
670
+
671
+ function safeEqual(
672
+ actual: string,
673
+ expected: string
674
+ ): boolean {
675
+ const actualBuffer =
676
+ Buffer.from(
677
+ actual,
678
+ "base64url"
679
+ );
680
+ const expectedBuffer =
681
+ Buffer.from(
682
+ expected,
683
+ "base64url"
684
+ );
685
+
686
+ return (
687
+ actualBuffer.length ===
688
+ expectedBuffer.length &&
689
+ timingSafeEqual(
690
+ actualBuffer,
691
+ expectedBuffer
692
+ )
693
+ );
694
+ }