@chidchanun/bcp 0.1.6 → 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.
@@ -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
+ }