@chidchanun/bcp 0.2.3 → 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.
@@ -9,6 +9,10 @@ import {
9
9
  type SessionClaims,
10
10
  type SessionCookieOptions,
11
11
  } from "./session.js";
12
+ import type {
13
+ AuthSessionStore,
14
+ AuthSessionStoreRecord,
15
+ } from "./auth-session-store.js";
12
16
 
13
17
  export interface AuthUser {
14
18
  id: string | number;
@@ -24,7 +28,10 @@ export interface AuthSession<
24
28
  }
25
29
 
26
30
  export interface AuthOptions
27
- extends SessionCookieOptions {}
31
+ extends SessionCookieOptions {
32
+ store?: AuthSessionStore;
33
+ idleTimeout?: number;
34
+ }
28
35
 
29
36
  export interface AuthLoginOptions<
30
37
  TData extends object = Record<string, never>
@@ -49,9 +56,18 @@ export interface AuthApi<
49
56
  logout(
50
57
  options?: AuthOptions
51
58
  ): Promise<void>;
59
+ logoutAll(
60
+ options?: AuthOptions
61
+ ): Promise<number>;
52
62
  rotateSession(
53
63
  options?: AuthOptions
54
64
  ): Promise<AuthSession<TUser, TData> | null>;
65
+ revokeSession(
66
+ sid: string
67
+ ): Promise<boolean>;
68
+ revokeUserSessions(
69
+ userId: string | number
70
+ ): Promise<number>;
55
71
  }
56
72
 
57
73
  interface AuthSessionPayload<
@@ -70,45 +86,82 @@ export async function auth<
70
86
  options: AuthOptions = {}
71
87
  ): Promise<AuthSession<TUser, TData> | null> {
72
88
  const session =
73
- await readSession<
74
- AuthSessionPayload<
75
- TUser,
76
- TData
77
- >
89
+ await readSignedAuthSession<
90
+ TUser,
91
+ TData
78
92
  >(
79
- options
93
+ toSessionCookieOptions(
94
+ options
95
+ )
80
96
  );
81
97
 
82
98
  if (!session) {
83
99
  return null;
84
100
  }
85
101
 
102
+ const idleTimeout =
103
+ resolveIdleTimeout(
104
+ options.idleTimeout
105
+ );
106
+ const store =
107
+ options.store;
108
+
86
109
  if (
87
- typeof session.sid !==
88
- "string" ||
89
- session.sid.trim().length ===
90
- 0 ||
91
- !isAuthUser(
92
- session.user
93
- )
110
+ idleTimeout !== undefined &&
111
+ !store
94
112
  ) {
113
+ throw new Error(
114
+ "BCP Auth: idleTimeout requires a server-side auth session store."
115
+ );
116
+ }
117
+
118
+ if (!store) {
119
+ return session;
120
+ }
121
+
122
+ const record =
123
+ await store.get(
124
+ session.sid
125
+ );
126
+
127
+ if (!record) {
95
128
  return null;
96
129
  }
97
130
 
131
+ const now =
132
+ currentUnixTime();
133
+ const userId =
134
+ serializeAuthUserId(
135
+ session.user.id
136
+ );
137
+
98
138
  if (
99
- session.data !== undefined &&
100
- !isPlainObject(
101
- session.data
102
- )
139
+ record.userId !== userId ||
140
+ record.revokedAt !== undefined &&
141
+ record.revokedAt !== null ||
142
+ record.expiresAt <= now
103
143
  ) {
104
144
  return null;
105
145
  }
106
146
 
107
- return session as
108
- AuthSession<
109
- TUser,
110
- TData
111
- >;
147
+ if (
148
+ idleTimeout !== undefined &&
149
+ record.lastSeenAt +
150
+ idleTimeout <= now
151
+ ) {
152
+ await store.revoke(
153
+ session.sid,
154
+ now
155
+ );
156
+ return null;
157
+ }
158
+
159
+ await store.touch(
160
+ session.sid,
161
+ now
162
+ );
163
+
164
+ return session;
112
165
  }
113
166
 
114
167
  export async function getSession<
@@ -148,10 +201,27 @@ export async function login<
148
201
  );
149
202
  }
150
203
 
204
+ const idleTimeout =
205
+ resolveIdleTimeout(
206
+ options.idleTimeout
207
+ );
208
+
209
+ if (
210
+ idleTimeout !== undefined &&
211
+ !options.store
212
+ ) {
213
+ throw new Error(
214
+ "BCP Auth: idleTimeout requires a server-side auth session store."
215
+ );
216
+ }
217
+
151
218
  const {
152
219
  data,
153
- ...sessionOptions
154
220
  } = options;
221
+ const sessionOptions =
222
+ toSessionCookieOptions(
223
+ options
224
+ );
155
225
  const payload:
156
226
  AuthSessionPayload<
157
227
  TUser,
@@ -173,7 +243,7 @@ export async function login<
173
243
  );
174
244
 
175
245
  const session =
176
- await auth<
246
+ await readSignedAuthSession<
177
247
  TUser,
178
248
  TData
179
249
  >(
@@ -186,17 +256,74 @@ export async function login<
186
256
  );
187
257
  }
188
258
 
259
+ if (options.store) {
260
+ await options.store.set(
261
+ createStoreRecord(
262
+ session
263
+ )
264
+ );
265
+ }
266
+
189
267
  return session;
190
268
  }
191
269
 
192
270
  export async function logout(
193
271
  options: AuthOptions = {}
194
272
  ): Promise<void> {
273
+ if (options.store) {
274
+ const current =
275
+ await auth(
276
+ options
277
+ );
278
+
279
+ if (current) {
280
+ await options.store.revoke(
281
+ current.sid,
282
+ currentUnixTime()
283
+ );
284
+ }
285
+ }
286
+
195
287
  await destroySession(
196
- options
288
+ toSessionCookieOptions(
289
+ options
290
+ )
197
291
  );
198
292
  }
199
293
 
294
+ export async function logoutAll(
295
+ options: AuthOptions = {}
296
+ ): Promise<number> {
297
+ const store =
298
+ requireAuthSessionStore(
299
+ options.store,
300
+ "logoutAll"
301
+ );
302
+ const current =
303
+ await auth(
304
+ options
305
+ );
306
+ let revoked = 0;
307
+
308
+ if (current) {
309
+ revoked =
310
+ await store.revokeUser(
311
+ serializeAuthUserId(
312
+ current.user.id
313
+ ),
314
+ currentUnixTime()
315
+ );
316
+ }
317
+
318
+ await destroySession(
319
+ toSessionCookieOptions(
320
+ options
321
+ )
322
+ );
323
+
324
+ return revoked;
325
+ }
326
+
200
327
  export async function rotateSession<
201
328
  TUser extends AuthUser = AuthUser,
202
329
  TData extends object = Record<string, never>
@@ -215,26 +342,71 @@ export async function rotateSession<
215
342
  return null;
216
343
  }
217
344
 
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
- }
345
+ const rotated =
346
+ await login<
347
+ TUser,
348
+ TData
349
+ >(
350
+ current.user,
351
+ {
352
+ ...options,
353
+ issuer:
354
+ options.issuer ??
355
+ current.iss,
356
+ audience:
357
+ options.audience ??
358
+ current.aud,
359
+ ...(current.data === undefined
360
+ ? {}
361
+ : {
362
+ data:
363
+ current.data,
364
+ }),
365
+ }
366
+ );
367
+
368
+ if (options.store) {
369
+ await options.store.revoke(
370
+ current.sid,
371
+ currentUnixTime()
372
+ );
373
+ }
374
+
375
+ return rotated;
376
+ }
377
+
378
+ export async function revokeSession(
379
+ sid: string,
380
+ store: AuthSessionStore
381
+ ): Promise<boolean> {
382
+ assertSessionId(
383
+ sid
384
+ );
385
+ assertAuthSessionStore(
386
+ store
387
+ );
388
+
389
+ return Boolean(
390
+ await store.revoke(
391
+ sid,
392
+ currentUnixTime()
393
+ )
394
+ );
395
+ }
396
+
397
+ export async function revokeUserSessions(
398
+ userId: string | number,
399
+ store: AuthSessionStore
400
+ ): Promise<number> {
401
+ assertAuthSessionStore(
402
+ store
403
+ );
404
+
405
+ return store.revokeUser(
406
+ serializeAuthUserId(
407
+ userId
408
+ ),
409
+ currentUnixTime()
238
410
  );
239
411
  }
240
412
 
@@ -297,6 +469,13 @@ export function createAuth<
297
469
  ...defaults,
298
470
  ...options,
299
471
  }),
472
+ logoutAll: (
473
+ options = {}
474
+ ) =>
475
+ logoutAll({
476
+ ...defaults,
477
+ ...options,
478
+ }),
300
479
  rotateSession: (
301
480
  options = {}
302
481
  ) =>
@@ -307,9 +486,218 @@ export function createAuth<
307
486
  ...defaults,
308
487
  ...options,
309
488
  }),
489
+ revokeSession: (
490
+ sid
491
+ ) =>
492
+ revokeSession(
493
+ sid,
494
+ requireAuthSessionStore(
495
+ defaults.store,
496
+ "revokeSession"
497
+ )
498
+ ),
499
+ revokeUserSessions: (
500
+ userId
501
+ ) =>
502
+ revokeUserSessions(
503
+ userId,
504
+ requireAuthSessionStore(
505
+ defaults.store,
506
+ "revokeUserSessions"
507
+ )
508
+ ),
509
+ };
510
+ }
511
+
512
+ async function readSignedAuthSession<
513
+ TUser extends AuthUser,
514
+ TData extends object
515
+ >(
516
+ options: SessionCookieOptions
517
+ ): Promise<AuthSession<TUser, TData> | null> {
518
+ const session =
519
+ await readSession<
520
+ AuthSessionPayload<
521
+ TUser,
522
+ TData
523
+ >
524
+ >(
525
+ options
526
+ );
527
+
528
+ if (!session) {
529
+ return null;
530
+ }
531
+
532
+ if (
533
+ typeof session.sid !== "string" ||
534
+ session.sid.trim().length === 0 ||
535
+ !isAuthUser(
536
+ session.user
537
+ )
538
+ ) {
539
+ return null;
540
+ }
541
+
542
+ if (
543
+ session.data !== undefined &&
544
+ !isPlainObject(
545
+ session.data
546
+ )
547
+ ) {
548
+ return null;
549
+ }
550
+
551
+ return session as
552
+ AuthSession<
553
+ TUser,
554
+ TData
555
+ >;
556
+ }
557
+
558
+ function createStoreRecord<
559
+ TUser extends AuthUser,
560
+ TData extends object
561
+ >(
562
+ session: AuthSession<
563
+ TUser,
564
+ TData
565
+ >
566
+ ): AuthSessionStoreRecord {
567
+ return {
568
+ sid:
569
+ session.sid,
570
+ userId:
571
+ serializeAuthUserId(
572
+ session.user.id
573
+ ),
574
+ createdAt:
575
+ session.iat,
576
+ expiresAt:
577
+ session.exp,
578
+ lastSeenAt:
579
+ session.iat,
580
+ revokedAt:
581
+ null,
310
582
  };
311
583
  }
312
584
 
585
+ function toSessionCookieOptions(
586
+ options: AuthOptions
587
+ ): SessionCookieOptions {
588
+ const {
589
+ store: _store,
590
+ idleTimeout: _idleTimeout,
591
+ ...sessionOptions
592
+ } = options;
593
+
594
+ return sessionOptions;
595
+ }
596
+
597
+ function serializeAuthUserId(
598
+ value: string | number
599
+ ): string {
600
+ if (
601
+ typeof value === "string"
602
+ ) {
603
+ const normalized =
604
+ value.trim();
605
+
606
+ if (!normalized) {
607
+ throw new TypeError(
608
+ "BCP Auth: user id must not be empty."
609
+ );
610
+ }
611
+
612
+ return `string:${normalized}`;
613
+ }
614
+
615
+ if (
616
+ !Number.isFinite(
617
+ value
618
+ )
619
+ ) {
620
+ throw new TypeError(
621
+ "BCP Auth: numeric user id must be finite."
622
+ );
623
+ }
624
+
625
+ return `number:${value}`;
626
+ }
627
+
628
+ function resolveIdleTimeout(
629
+ value: number | undefined
630
+ ): number | undefined {
631
+ if (
632
+ value === undefined
633
+ ) {
634
+ return undefined;
635
+ }
636
+
637
+ if (
638
+ !Number.isFinite(
639
+ value
640
+ ) ||
641
+ value <= 0
642
+ ) {
643
+ throw new TypeError(
644
+ "BCP Auth: idleTimeout must be a positive finite number of seconds."
645
+ );
646
+ }
647
+
648
+ return Math.floor(
649
+ value
650
+ );
651
+ }
652
+
653
+ function requireAuthSessionStore(
654
+ store: AuthSessionStore | undefined,
655
+ operation: string
656
+ ): AuthSessionStore {
657
+ if (!store) {
658
+ throw new Error(
659
+ `BCP Auth: ${operation} requires a server-side auth session store.`
660
+ );
661
+ }
662
+
663
+ assertAuthSessionStore(
664
+ store
665
+ );
666
+
667
+ return store;
668
+ }
669
+
670
+ function assertAuthSessionStore(
671
+ store: AuthSessionStore
672
+ ): void {
673
+ if (
674
+ !store ||
675
+ typeof store !== "object" ||
676
+ typeof store.set !== "function" ||
677
+ typeof store.get !== "function" ||
678
+ typeof store.touch !== "function" ||
679
+ typeof store.revoke !== "function" ||
680
+ typeof store.revokeUser !== "function"
681
+ ) {
682
+ throw new TypeError(
683
+ "BCP Auth: session store must implement set, get, touch, revoke and revokeUser."
684
+ );
685
+ }
686
+ }
687
+
688
+ function assertSessionId(
689
+ sid: string
690
+ ): void {
691
+ if (
692
+ typeof sid !== "string" ||
693
+ sid.trim() === ""
694
+ ) {
695
+ throw new TypeError(
696
+ "BCP Auth: session id must be a non-empty string."
697
+ );
698
+ }
699
+ }
700
+
313
701
  function assertAuthUser(
314
702
  value: unknown
315
703
  ): asserts value is AuthUser {
@@ -368,9 +756,14 @@ function isPlainObject(
368
756
  );
369
757
 
370
758
  return (
371
- prototype ===
372
- Object.prototype ||
373
- prototype ===
374
- null
759
+ prototype === Object.prototype ||
760
+ prototype === null
761
+ );
762
+ }
763
+
764
+ function currentUnixTime(): number {
765
+ return Math.floor(
766
+ Date.now() /
767
+ 1000
375
768
  );
376
769
  }