auth-vir 3.0.1 → 3.1.1

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.
@@ -91,6 +91,12 @@ export type BackendAuthClientConfig<
91
91
  */
92
92
  isDev: boolean;
93
93
  } & PartialWithUndefined<{
94
+ /** If this returns true, logging will be enabled while handling the relevant session. */
95
+ enableLogging(params: {
96
+ user: DatabaseUser | undefined;
97
+ userId: UserId | undefined;
98
+ assumedUserParams: AssumedUserParams | undefined;
99
+ }): boolean;
94
100
  /**
95
101
  * Overwrite the header name used for tracking is an admin is assuming the identity of
96
102
  * another user.
@@ -103,6 +109,8 @@ export type BackendAuthClientConfig<
103
109
  generateServiceOrigin(params: {
104
110
  requestHeaders: Readonly<IncomingHttpHeaders>;
105
111
  }): MaybePromise<undefined | string>;
112
+ /** If provided, logs will be sent to this method. */
113
+ log?: (message: string, extraData: AnyObject) => void;
106
114
  /**
107
115
  * Set this to allow specific users (determined by `canAssumeUser`) to assume the identity
108
116
  * of other users. This should only be used for admins so that they can troubleshoot user
@@ -197,6 +205,30 @@ export class BackendAuthClient<
197
205
  protected readonly config: BackendAuthClientConfig<DatabaseUser, UserId, AssumedUserParams>,
198
206
  ) {}
199
207
 
208
+ /** Conditionally logs a message if logging is enabled for the given user context. */
209
+ protected logForUser(
210
+ params: {
211
+ user: DatabaseUser | undefined;
212
+ userId: UserId | undefined;
213
+ assumedUserParams: AssumedUserParams | undefined;
214
+ },
215
+ message: string,
216
+ extra?: Record<string, unknown>,
217
+ ): void {
218
+ if (this.config.enableLogging?.(params)) {
219
+ const extraData = {
220
+ userId: params.userId,
221
+ ...extra,
222
+ };
223
+
224
+ if (this.config.log) {
225
+ this.config.log(message, extraData);
226
+ } else {
227
+ console.info(`[auth-vir] ${message}`, extraData);
228
+ }
229
+ }
230
+ }
231
+
200
232
  /** Get all the parameters used for cookie generation. */
201
233
  protected async getCookieParams({
202
234
  isSignUpCookie,
@@ -212,7 +244,9 @@ export class BackendAuthClient<
212
244
  requestHeaders: Readonly<IncomingHttpHeaders> | undefined;
213
245
  }): Promise<Readonly<CookieParams>> {
214
246
  const serviceOrigin = requestHeaders
215
- ? await this.config.generateServiceOrigin?.({requestHeaders})
247
+ ? await this.config.generateServiceOrigin?.({
248
+ requestHeaders,
249
+ })
216
250
  : undefined;
217
251
 
218
252
  return {
@@ -248,6 +282,17 @@ export class BackendAuthClient<
248
282
  });
249
283
 
250
284
  if (!authenticatedUser) {
285
+ this.logForUser(
286
+ {
287
+ user: undefined,
288
+ userId,
289
+ assumedUserParams: assumingUser,
290
+ },
291
+ 'getUserFromDatabase returned no user',
292
+ {
293
+ isSignUpCookie,
294
+ },
295
+ );
251
296
  return undefined;
252
297
  }
253
298
 
@@ -273,6 +318,18 @@ export class BackendAuthClient<
273
318
  });
274
319
 
275
320
  if (isExpiredAlready) {
321
+ this.logForUser(
322
+ {
323
+ user: undefined,
324
+ userId: userIdResult.userId,
325
+ assumedUserParams: undefined,
326
+ },
327
+ 'Session refresh denied: JWT already expired (even with clock skew tolerance)',
328
+ {
329
+ jwtExpiration: userIdResult.jwtExpiration,
330
+ now: JSON.stringify(now),
331
+ },
332
+ );
276
333
  return undefined;
277
334
  }
278
335
 
@@ -290,6 +347,19 @@ export class BackendAuthClient<
290
347
  });
291
348
 
292
349
  if (isSessionExpired) {
350
+ this.logForUser(
351
+ {
352
+ user: undefined,
353
+ userId: userIdResult.userId,
354
+ assumedUserParams: undefined,
355
+ },
356
+ 'Session refresh denied: max session duration exceeded',
357
+ {
358
+ sessionStartedAt: userIdResult.sessionStartedAt,
359
+ maxSessionEndDate: JSON.stringify(maxSessionEndDate),
360
+ now: JSON.stringify(now),
361
+ },
362
+ );
293
363
  return undefined;
294
364
  }
295
365
  }
@@ -327,6 +397,18 @@ export class BackendAuthClient<
327
397
  }),
328
398
  };
329
399
  } else {
400
+ this.logForUser(
401
+ {
402
+ user: undefined,
403
+ userId: userIdResult.userId,
404
+ assumedUserParams: undefined,
405
+ },
406
+ 'Session refresh skipped: not yet ready for refresh',
407
+ {
408
+ jwtIssuedAt: userIdResult.jwtIssuedAt,
409
+ sessionRefreshStartTime,
410
+ },
411
+ );
330
412
  return undefined;
331
413
  }
332
414
  }
@@ -390,6 +472,17 @@ export class BackendAuthClient<
390
472
  isSignUpCookie ? AuthCookieName.SignUp : AuthCookieName.Auth,
391
473
  );
392
474
  if (!userIdResult) {
475
+ this.logForUser(
476
+ {
477
+ user: undefined,
478
+ userId: undefined,
479
+ assumedUserParams: undefined,
480
+ },
481
+ 'getSecureUser: failed to extract user ID from request headers (invalid JWT, missing cookie, or CSRF mismatch)',
482
+ {
483
+ isSignUpCookie,
484
+ },
485
+ );
393
486
  return undefined;
394
487
  }
395
488
 
@@ -401,6 +494,17 @@ export class BackendAuthClient<
401
494
  });
402
495
 
403
496
  if (!user) {
497
+ this.logForUser(
498
+ {
499
+ user: undefined,
500
+ userId: userIdResult.userId,
501
+ assumedUserParams: undefined,
502
+ },
503
+ 'getSecureUser: user not found in database',
504
+ {
505
+ isSignUpCookie,
506
+ },
507
+ );
404
508
  return undefined;
405
509
  }
406
510
 
@@ -435,7 +539,9 @@ export class BackendAuthClient<
435
539
  const parsedKeys = cachedParsedKeys || (await parseJwtKeys(rawJwtKeys));
436
540
 
437
541
  if (!cachedParsedKeys) {
438
- this.cachedParsedJwtKeys = {[cacheKey]: parsedKeys};
542
+ this.cachedParsedJwtKeys = {
543
+ [cacheKey]: parsedKeys,
544
+ };
439
545
  }
440
546
  return {
441
547
  jwtKeys: parsedKeys,
@@ -469,7 +575,6 @@ export class BackendAuthClient<
469
575
  isSignUpCookie: true,
470
576
  requestHeaders: undefined,
471
577
  }),
472
- this.config.csrf,
473
578
  )
474
579
  : undefined;
475
580
  const authCookieHeaders =
@@ -479,26 +584,41 @@ export class BackendAuthClient<
479
584
  isSignUpCookie: false,
480
585
  requestHeaders: undefined,
481
586
  }),
482
- this.config.csrf,
483
587
  )
484
588
  : undefined;
485
589
 
486
- const setCookieHeader: {
487
- 'set-cookie': string[];
488
- } = {
590
+ return {
489
591
  'set-cookie': mergeHeaderValues(
490
592
  signUpCookieHeaders?.['set-cookie'],
491
593
  authCookieHeaders?.['set-cookie'],
492
594
  ),
493
595
  };
494
- const csrfTokenHeader = {
495
- ...authCookieHeaders,
496
- ...signUpCookieHeaders,
497
- };
596
+ }
597
+
598
+ /**
599
+ * Refreshes a login session by reissuing the auth cookie with the same CSRF token instead of
600
+ * generating a new one.
601
+ */
602
+ protected async refreshLoginHeaders({
603
+ userId,
604
+ cookieParams,
605
+ existingUserIdResult,
606
+ }: {
607
+ userId: UserId;
608
+ cookieParams: Readonly<CookieParams>;
609
+ existingUserIdResult: Readonly<UserIdResult<UserId>>;
610
+ }): Promise<Record<string, string>> {
611
+ const {cookie} = await generateAuthCookie(
612
+ {
613
+ csrfToken: existingUserIdResult.csrfToken,
614
+ userId,
615
+ sessionStartedAt: existingUserIdResult.sessionStartedAt,
616
+ },
617
+ cookieParams,
618
+ );
498
619
 
499
620
  return {
500
- ...csrfTokenHeader,
501
- ...setCookieHeader,
621
+ 'set-cookie': cookie,
502
622
  };
503
623
  }
504
624
 
@@ -521,7 +641,6 @@ export class BackendAuthClient<
521
641
  isSignUpCookie: !isSignUpCookie,
522
642
  requestHeaders,
523
643
  }),
524
- this.config.csrf,
525
644
  )
526
645
  : undefined;
527
646
 
@@ -531,17 +650,19 @@ export class BackendAuthClient<
531
650
  this.config.csrf,
532
651
  isSignUpCookie ? AuthCookieName.SignUp : AuthCookieName.Auth,
533
652
  );
534
- const sessionStartedAt = existingUserIdResult?.sessionStartedAt;
535
653
 
536
- const newCookieHeaders = await generateSuccessfulLoginHeaders(
537
- userId,
538
- await this.getCookieParams({
539
- isSignUpCookie,
540
- requestHeaders,
541
- }),
542
- this.config.csrf,
543
- sessionStartedAt,
544
- );
654
+ const cookieParams = await this.getCookieParams({
655
+ isSignUpCookie,
656
+ requestHeaders,
657
+ });
658
+
659
+ const newCookieHeaders = existingUserIdResult
660
+ ? await this.refreshLoginHeaders({
661
+ userId,
662
+ cookieParams,
663
+ existingUserIdResult,
664
+ })
665
+ : await generateSuccessfulLoginHeaders(userId, cookieParams, this.config.csrf);
545
666
 
546
667
  return {
547
668
  ...newCookieHeaders,
@@ -584,13 +705,19 @@ export class BackendAuthClient<
584
705
  const secureUser = await this.getSecureUser(params);
585
706
 
586
707
  if (secureUser) {
587
- return {secureUser};
708
+ return {
709
+ secureUser,
710
+ };
588
711
  }
589
712
 
590
713
  // eslint-disable-next-line @typescript-eslint/no-deprecated
591
714
  const insecureUser = await this.getInsecureUser(params);
592
715
 
593
- return insecureUser ? {insecureUser} : {};
716
+ return insecureUser
717
+ ? {
718
+ insecureUser,
719
+ }
720
+ : {};
594
721
  }
595
722
 
596
723
  /**
@@ -618,6 +745,14 @@ export class BackendAuthClient<
618
745
  );
619
746
 
620
747
  if (!userIdResult) {
748
+ this.logForUser(
749
+ {
750
+ user: undefined,
751
+ userId: undefined,
752
+ assumedUserParams: undefined,
753
+ },
754
+ 'getInsecureUser: failed to extract user ID from cookie (invalid JWT or missing cookie)',
755
+ );
621
756
  return undefined;
622
757
  }
623
758
 
@@ -629,6 +764,14 @@ export class BackendAuthClient<
629
764
  });
630
765
 
631
766
  if (!user) {
767
+ this.logForUser(
768
+ {
769
+ user: undefined,
770
+ userId: userIdResult.userId,
771
+ assumedUserParams: undefined,
772
+ },
773
+ 'getInsecureUser: user not found in database',
774
+ );
632
775
  return undefined;
633
776
  }
634
777
 
@@ -9,15 +9,14 @@ import {
9
9
  import {type AnyDuration} from 'date-vir';
10
10
  import {listenToActivity} from 'detect-activity';
11
11
  import {type EmptyObject} from 'type-fest';
12
+ import {type CsrfTokenStore} from '../csrf-token-store.js';
12
13
  import {
13
14
  type CsrfHeaderNameOption,
14
- CsrfTokenFailureReason,
15
15
  defaultAllowedClockSkew,
16
16
  extractCsrfTokenHeader,
17
17
  getCurrentCsrfToken,
18
18
  resolveCsrfHeaderName,
19
19
  storeCsrfToken,
20
- wipeCurrentCsrfToken,
21
20
  } from '../csrf-token.js';
22
21
  import {AuthHeaderName} from '../headers.js';
23
22
 
@@ -85,6 +84,7 @@ export type FrontendAuthClientConfig = Readonly<{
85
84
 
86
85
  overrides: PartialWithUndefined<{
87
86
  localStorage: Pick<Storage, 'setItem' | 'removeItem' | 'getItem'>;
87
+ csrfTokenStore: CsrfTokenStore;
88
88
  }>;
89
89
  }>;
90
90
 
@@ -112,7 +112,9 @@ export class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject =
112
112
  });
113
113
  }
114
114
  },
115
- debounce: config.checkUser.debounce || {minutes: 1},
115
+ debounce: config.checkUser.debounce || {
116
+ minutes: 1,
117
+ },
116
118
  fireImmediately: false,
117
119
  });
118
120
  }
@@ -128,20 +130,14 @@ export class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject =
128
130
  }
129
131
 
130
132
  /** Wraps {@link getCurrentCsrfToken} to automatically handle wiping an invalid CSRF token. */
131
- public getCurrentCsrfToken(): string | undefined {
132
- const csrfTokenResult = getCurrentCsrfToken({
133
+ public async getCurrentCsrfToken(): Promise<string | undefined> {
134
+ const csrfTokenResult = await getCurrentCsrfToken({
133
135
  ...this.config.csrf,
134
- localStorage: this.config.overrides?.localStorage,
136
+ csrfTokenStore: this.config.overrides?.csrfTokenStore,
135
137
  allowedClockSkew: this.config.allowedClockSkew || defaultAllowedClockSkew,
136
138
  });
137
139
 
138
140
  if (csrfTokenResult.failure) {
139
- if (csrfTokenResult.failure !== CsrfTokenFailureReason.DoesNotExist) {
140
- wipeCurrentCsrfToken({
141
- ...this.config.csrf,
142
- localStorage: this.config.overrides?.localStorage,
143
- });
144
- }
145
141
  return undefined;
146
142
  }
147
143
 
@@ -162,9 +158,7 @@ export class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject =
162
158
  if (!assumedUserParams) {
163
159
  localStorage.removeItem(storageKey);
164
160
  return true;
165
- }
166
-
167
- if (!(await this.config.canAssumeUser?.())) {
161
+ } else if (!(await this.config.canAssumeUser?.())) {
168
162
  return false;
169
163
  }
170
164
 
@@ -195,8 +189,8 @@ export class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject =
195
189
  * `@augment-vir/common`](https://electrovir.github.io/augment-vir/functions/mergeDeep.html) to
196
190
  * combine them with these.
197
191
  */
198
- public createAuthenticatedRequestInit(): RequestInit {
199
- const csrfToken = this.getCurrentCsrfToken();
192
+ public async createAuthenticatedRequestInit(): Promise<RequestInit> {
193
+ const csrfToken = await this.getCurrentCsrfToken();
200
194
 
201
195
  const assumedUser = this.getAssumedUser();
202
196
  const headers: HeadersInit = {
@@ -222,10 +216,6 @@ export class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject =
222
216
  /** Wipes the current user auth. */
223
217
  public async logout() {
224
218
  await this.config.authClearedCallback?.();
225
- wipeCurrentCsrfToken({
226
- ...this.config.csrf,
227
- localStorage: this.config.overrides?.localStorage,
228
- });
229
219
  }
230
220
 
231
221
  /**
@@ -259,9 +249,9 @@ export class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject =
259
249
  throw new Error('Did not receive any CSRF token.');
260
250
  }
261
251
 
262
- storeCsrfToken(csrfToken, {
252
+ await storeCsrfToken(csrfToken, {
263
253
  ...this.config.csrf,
264
- localStorage: this.config.overrides?.localStorage,
254
+ csrfTokenStore: this.config.overrides?.csrfTokenStore,
265
255
  });
266
256
  }
267
257
 
@@ -297,9 +287,9 @@ export class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject =
297
287
  allowedClockSkew: this.config.allowedClockSkew || defaultAllowedClockSkew,
298
288
  });
299
289
  if (csrfToken) {
300
- storeCsrfToken(csrfToken, {
290
+ await storeCsrfToken(csrfToken, {
301
291
  ...this.config.csrf,
302
- localStorage: this.config.overrides?.localStorage,
292
+ csrfTokenStore: this.config.overrides?.csrfTokenStore,
303
293
  });
304
294
  }
305
295
 
package/src/auth.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  extractCookieJwt,
8
8
  generateAuthCookie,
9
9
  } from './cookie.js';
10
+ import {type CsrfTokenStore} from './csrf-token-store.js';
10
11
  import {
11
12
  type CsrfHeaderNameOption,
12
13
  extractCsrfTokenHeader,
@@ -14,7 +15,6 @@ import {
14
15
  parseCsrfToken,
15
16
  resolveCsrfHeaderName,
16
17
  storeCsrfToken,
17
- wipeCurrentCsrfToken,
18
18
  } from './csrf-token.js';
19
19
  import {type ParseJwtParams} from './jwt/jwt.js';
20
20
  import {type JwtUserData} from './jwt/user-jwt.js';
@@ -202,48 +202,42 @@ export async function generateSuccessfulLoginHeaders(
202
202
  */
203
203
  export function generateLogoutHeaders(
204
204
  cookieConfig: Readonly<Pick<CookieParams, 'cookieName' | 'hostOrigin' | 'isDev'>>,
205
- csrfHeaderNameOption: Readonly<CsrfHeaderNameOption>,
206
205
  ): Record<string, string> {
207
- const csrfHeaderName = resolveCsrfHeaderName(csrfHeaderNameOption);
208
-
209
206
  return {
210
207
  'set-cookie': clearAuthCookie(cookieConfig),
211
- [csrfHeaderName]: 'redacted',
212
208
  };
213
209
  }
214
210
 
215
211
  /**
216
212
  * Store auth data on a client (frontend) after receiving an auth response from the host (backend).
217
- * Specifically, this stores the CSRF token into local storage (which doesn't need to be a secret).
218
- * Alternatively, if the given response failed, this will wipe the existing (if anyone) stored CSRF
213
+ * Specifically, this stores the CSRF token into IndexedDB (which doesn't need to be a secret).
214
+ * Alternatively, if the given response failed, this will wipe the existing (if any) stored CSRF
219
215
  * token.
220
216
  *
221
217
  * @category Auth : Client
222
218
  * @throws Error if no CSRF token header is found.
223
219
  */
224
- export function handleAuthResponse(
220
+ export async function handleAuthResponse(
225
221
  response: Readonly<Pick<Response, 'ok' | 'headers'>>,
226
222
  options: Readonly<CsrfHeaderNameOption> &
227
223
  PartialWithUndefined<{
228
224
  /**
229
- * Allows mocking or overriding the global `localStorage`.
225
+ * Allows mocking or overriding the default CSRF token store.
230
226
  *
231
- * @default globalThis.localStorage
227
+ * @default getDefaultCsrfTokenStore()
232
228
  */
233
- localStorage: Pick<Storage, 'setItem' | 'removeItem'>;
229
+ csrfTokenStore: CsrfTokenStore;
234
230
  }>,
235
- ) {
231
+ ): Promise<void> {
236
232
  if (!response.ok) {
237
- wipeCurrentCsrfToken(options);
238
233
  return;
239
234
  }
240
235
 
241
236
  const {csrfToken} = extractCsrfTokenHeader(response, options);
242
237
 
243
238
  if (!csrfToken) {
244
- wipeCurrentCsrfToken(options);
245
239
  throw new Error('Did not receive any CSRF token.');
246
240
  }
247
241
 
248
- storeCsrfToken(csrfToken, options);
242
+ await storeCsrfToken(csrfToken, options);
249
243
  }
package/src/cookie.ts CHANGED
@@ -91,7 +91,9 @@ export async function generateAuthCookie(
91
91
  HttpOnly: true,
92
92
  Path: '/',
93
93
  SameSite: 'Strict',
94
- 'MAX-AGE': convertDuration(cookieConfig.cookieDuration, {seconds: true}).seconds,
94
+ 'MAX-AGE': convertDuration(cookieConfig.cookieDuration, {
95
+ seconds: true,
96
+ }).seconds,
95
97
  Secure: !cookieConfig.isDev,
96
98
  }),
97
99
  expiration,
@@ -0,0 +1,54 @@
1
+ import {LocalDbClient} from 'local-db-client';
2
+ import {defineShape} from 'object-shape-tester';
3
+
4
+ const csrfTokenDbShapes = {
5
+ csrfToken: defineShape(''),
6
+ } as const;
7
+
8
+ /**
9
+ * The interface used for overriding the default CSRF token store in storage functions.
10
+ *
11
+ * @category Internal
12
+ */
13
+ export type CsrfTokenStore = {
14
+ /** Retrieves the stored CSRF token, if any. */
15
+ getCsrfToken(): Promise<string | undefined>;
16
+ /** Stores a CSRF token. */
17
+ setCsrfToken(value: string): Promise<void>;
18
+ /** Deletes the stored CSRF token. */
19
+ deleteCsrfToken(): Promise<void>;
20
+ };
21
+
22
+ async function createDefaultCsrfTokenStore(): Promise<CsrfTokenStore> {
23
+ const client = await LocalDbClient.createClient(csrfTokenDbShapes, {
24
+ storeName: 'auth-vir-csrf',
25
+ });
26
+
27
+ return {
28
+ async getCsrfToken() {
29
+ return (await client.load.csrfToken()) || undefined;
30
+ },
31
+ async setCsrfToken(value) {
32
+ await client.set.csrfToken(value);
33
+ },
34
+ async deleteCsrfToken() {
35
+ await client.delete.csrfToken();
36
+ },
37
+ };
38
+ }
39
+
40
+ /**
41
+ * The default {@link LocalDbClient} instance used for storing CSRF tokens. This uses a dedicated
42
+ * store name to avoid collisions with other storage. Lazily initialized to avoid crashes in Node.js
43
+ * environments where IndexedDB is not available.
44
+ *
45
+ * @category Internal
46
+ */
47
+ export async function getDefaultCsrfTokenStore(): Promise<CsrfTokenStore> {
48
+ if (!cachedStorePromise) {
49
+ cachedStorePromise = createDefaultCsrfTokenStore();
50
+ }
51
+ return cachedStorePromise;
52
+ }
53
+
54
+ let cachedStorePromise: Promise<CsrfTokenStore> | undefined;