@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.
- package/README.md +76 -46
- package/docs/README.md +77 -126
- package/docs/api-manifest.json +3 -2
- package/docs/api-reference.md +22 -2
- package/docs/auth-route-guards.md +72 -37
- package/docs/auth-session-store.md +204 -0
- package/docs/authentication.md +128 -44
- package/docs/docs-web-manifest.json +5 -3
- package/docs/platform-manifest.json +12 -4
- package/docs/releases/0.2.5.md +152 -0
- package/package.json +1 -1
- package/packages/client/src/auth.ts +16 -0
- package/packages/server/src/auth-guard.ts +95 -82
- package/packages/server/src/auth-session-store.ts +318 -0
- package/packages/server/src/auth.ts +443 -50
|
@@ -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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
TData
|
|
77
|
-
>
|
|
89
|
+
await readSignedAuthSession<
|
|
90
|
+
TUser,
|
|
91
|
+
TData
|
|
78
92
|
>(
|
|
79
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
}
|