auth-vir 2.7.2 → 3.0.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.
@@ -10,7 +10,6 @@ import {
10
10
  createUtcFullDate,
11
11
  getNowInUtcTimezone,
12
12
  isDateAfter,
13
- negateDuration,
14
13
  type AnyDuration,
15
14
  } from 'date-vir';
16
15
  import {type IncomingHttpHeaders, type OutgoingHttpHeaders} from 'node:http';
@@ -22,11 +21,16 @@ import {
22
21
  insecureExtractUserIdFromCookieAlone,
23
22
  type UserIdResult,
24
23
  } from '../auth.js';
25
- import {AuthCookieName, type CookieParams} from '../cookie.js';
24
+ import {AuthCookieName, generateAuthCookie, type CookieParams} from '../cookie.js';
25
+ import {
26
+ defaultAllowedClockSkew,
27
+ resolveCsrfHeaderName,
28
+ type CsrfHeaderNameOption,
29
+ } from '../csrf-token.js';
26
30
  import {AuthHeaderName, mergeHeaderValues} from '../headers.js';
27
31
  import {generateNewJwtKeys, parseJwtKeys, type JwtKeys, type RawJwtKeys} from '../jwt/jwt-keys.js';
28
- import {type CreateJwtParams} from '../jwt/jwt.js';
29
- import {authLog} from '../log.js';
32
+ import {type CreateJwtParams, type ParseJwtParams} from '../jwt/jwt.js';
33
+ import {isSessionRefreshReady} from './is-session-refresh-ready.js';
30
34
 
31
35
  /**
32
36
  * Output from `BackendAuthClient.getSecureUser()`.
@@ -57,9 +61,9 @@ export type BackendAuthClientConfig<
57
61
  DatabaseUser extends AnyObject,
58
62
  UserId extends string | number,
59
63
  AssumedUserParams extends JsonCompatibleObject = EmptyObject,
60
- CsrfHeaderName extends string = AuthHeaderName.CsrfToken,
61
64
  > = Readonly<
62
65
  {
66
+ csrf: Readonly<CsrfHeaderNameOption>;
63
67
  /** The origin of your backend that is offering auth cookies. */
64
68
  serviceOrigin: string;
65
69
  /** Finds the relevant user from your own database. */
@@ -73,6 +77,7 @@ export type BackendAuthClientConfig<
73
77
  * their user identity. Otherwise, this is `undefined`.
74
78
  */
75
79
  assumingUser: AssumedUserParams | undefined;
80
+ requestHeaders: Readonly<IncomingHttpHeaders>;
76
81
  }) => MaybePromise<DatabaseUser | undefined | null>;
77
82
  /**
78
83
  * Get JWT keys produced by {@link generateNewJwtKeys}. Make sure that each time this is
@@ -86,6 +91,11 @@ export type BackendAuthClientConfig<
86
91
  */
87
92
  isDev: boolean;
88
93
  } & PartialWithUndefined<{
94
+ /**
95
+ * Overwrite the header name used for tracking is an admin is assuming the identity of
96
+ * another user.
97
+ */
98
+ assumedUserHeaderName: string;
89
99
  /**
90
100
  * Optionally generate a service origin from request headers. The generated origin is used
91
101
  * for set-cookie headers.
@@ -139,18 +149,21 @@ export type BackendAuthClientConfig<
139
149
  *
140
150
  * @default {minutes: 2}
141
151
  */
142
- sessionRefreshTimeout: Readonly<AnyDuration>;
152
+ sessionRefreshStartTime: Readonly<AnyDuration>;
143
153
  /**
144
154
  * The maximum duration a session can last, regardless of activity. After this time, the
145
155
  * user will be logged out even if they are actively using the application.
146
156
  *
147
- * @default {weeks: 2}
157
+ * @default {days: 1.5}
148
158
  */
149
159
  maxSessionDuration: Readonly<AnyDuration>;
150
- overrides: PartialWithUndefined<{
151
- csrfHeaderName: CsrfHeaderName;
152
- assumedUserHeaderName: string;
153
- }>;
160
+ /**
161
+ * Allowed clock skew tolerance for JWT and CSRF token expiration checks. Accounts for
162
+ * differences between server and client clocks.
163
+ *
164
+ * @default {minutes: 5}
165
+ */
166
+ allowedClockSkew: Readonly<AnyDuration>;
154
167
  }>
155
168
  >;
156
169
 
@@ -158,12 +171,12 @@ const defaultSessionIdleTimeout: Readonly<AnyDuration> = {
158
171
  minutes: 20,
159
172
  };
160
173
 
161
- const defaultSessionRefreshTimeout: Readonly<AnyDuration> = {
174
+ const defaultSessionRefreshStartTime: Readonly<AnyDuration> = {
162
175
  minutes: 2,
163
176
  };
164
177
 
165
178
  const defaultMaxSessionDuration: Readonly<AnyDuration> = {
166
- weeks: 2,
179
+ days: 1.5,
167
180
  };
168
181
 
169
182
  /**
@@ -171,23 +184,17 @@ const defaultMaxSessionDuration: Readonly<AnyDuration> = {
171
184
  * a backend environment as it accesses native Node packages.
172
185
  *
173
186
  * @category Auth : Host
174
- * @category Client
187
+ * @category Clients
175
188
  */
176
189
  export class BackendAuthClient<
177
190
  DatabaseUser extends AnyObject,
178
191
  UserId extends string | number,
179
192
  AssumedUserParams extends AnyObject = EmptyObject,
180
- CsrfHeaderName extends string = AuthHeaderName.CsrfToken,
181
193
  > {
182
194
  protected cachedParsedJwtKeys: Record<string, Readonly<JwtKeys>> = {};
183
195
 
184
196
  constructor(
185
- protected readonly config: BackendAuthClientConfig<
186
- DatabaseUser,
187
- UserId,
188
- AssumedUserParams,
189
- CsrfHeaderName
190
- >,
197
+ protected readonly config: BackendAuthClientConfig<DatabaseUser, UserId, AssumedUserParams>,
191
198
  ) {}
192
199
 
193
200
  /** Get all the parameters used for cookie generation. */
@@ -222,10 +229,12 @@ export class BackendAuthClient<
222
229
  isSignUpCookie,
223
230
  userId,
224
231
  assumingUser,
232
+ requestHeaders,
225
233
  }: {
226
234
  userId: UserId | undefined;
227
235
  assumingUser: AssumedUserParams | undefined;
228
236
  isSignUpCookie: boolean;
237
+ requestHeaders: IncomingHttpHeaders;
229
238
  }): Promise<undefined | DatabaseUser> {
230
239
  if (!userId) {
231
240
  return undefined;
@@ -235,6 +244,7 @@ export class BackendAuthClient<
235
244
  assumingUser,
236
245
  userId,
237
246
  isSignUpCookie,
247
+ requestHeaders,
238
248
  });
239
249
 
240
250
  if (!authenticatedUser) {
@@ -254,17 +264,15 @@ export class BackendAuthClient<
254
264
  }): Promise<OutgoingHttpHeaders | undefined> {
255
265
  const now = getNowInUtcTimezone();
256
266
 
257
- /** Double check that the JWT hasn't already expired. */
267
+ const clockSkew = this.config.allowedClockSkew || defaultAllowedClockSkew;
268
+
269
+ /** Double check that the JWT hasn't already expired (with clock skew tolerance). */
258
270
  const isExpiredAlready = isDateAfter({
259
271
  fullDate: now,
260
- relativeTo: userIdResult.jwtExpiration,
272
+ relativeTo: calculateRelativeDate(userIdResult.jwtExpiration, clockSkew),
261
273
  });
262
274
 
263
275
  if (isExpiredAlready) {
264
- authLog('auth-vir: SESSION EXPIRED - JWT already expired, user will be logged out', {
265
- userId: userIdResult.userId,
266
- jwtExpiration: userIdResult.jwtExpiration,
267
- });
268
276
  return undefined;
269
277
  }
270
278
 
@@ -282,49 +290,42 @@ export class BackendAuthClient<
282
290
  });
283
291
 
284
292
  if (isSessionExpired) {
285
- authLog(
286
- 'auth-vir: SESSION EXPIRED - max session duration exceeded, user will be logged out',
287
- {
288
- userId: userIdResult.userId,
289
- sessionStartedAt: userIdResult.sessionStartedAt,
290
- maxSessionDuration,
291
- },
292
- );
293
293
  return undefined;
294
294
  }
295
295
  }
296
296
 
297
- /**
298
- * This check performs the following: the current time + the refresh threshold > JWT
299
- * expiration.
300
- *
301
- * Visually, this check looks like this:
302
- *
303
- * X C=======Y=======R Z
304
- *
305
- * - C = current time
306
- * - R = C + refresh threshold
307
- * - `=` = the time frame in which {@link isRefreshReady} = true.
308
- * - X = JWT expiration that has already expired (rejected by {@link isExpiredAlready}.
309
- * - Y = JWT expiration within the refresh threshold: {@link isRefreshReady} = true.
310
- * - Z = JWT expiration outside the refresh threshold: {@link isRefreshReady} = false.
311
- */
312
- const sessionRefreshTimeout =
313
- this.config.sessionRefreshTimeout || defaultSessionRefreshTimeout;
314
- const isRefreshReady = isDateAfter({
315
- fullDate: now,
316
- relativeTo: calculateRelativeDate(
317
- userIdResult.jwtExpiration,
318
- negateDuration(sessionRefreshTimeout),
319
- ),
297
+ const sessionRefreshStartTime =
298
+ this.config.sessionRefreshStartTime || defaultSessionRefreshStartTime;
299
+ const isRefreshReady = isSessionRefreshReady({
300
+ now,
301
+ jwtIssuedAt: userIdResult.jwtIssuedAt,
302
+ sessionRefreshStartTime,
320
303
  });
321
304
 
322
305
  if (isRefreshReady) {
323
- return this.createLoginHeaders({
306
+ const isSignUpCookie = userIdResult.cookieName === AuthCookieName.SignUp;
307
+ const cookieParams = await this.getCookieParams({
308
+ isSignUpCookie,
324
309
  requestHeaders,
325
- userId: userIdResult.userId,
326
- isSignUpCookie: userIdResult.cookieName === AuthCookieName.SignUp,
327
310
  });
311
+
312
+ const csrfHeaderName = resolveCsrfHeaderName(this.config.csrf);
313
+ const {cookie, expiration} = await generateAuthCookie(
314
+ {
315
+ csrfToken: userIdResult.csrfToken,
316
+ userId: userIdResult.userId,
317
+ sessionStartedAt: userIdResult.sessionStartedAt || Date.now(),
318
+ },
319
+ cookieParams,
320
+ );
321
+
322
+ return {
323
+ 'set-cookie': cookie,
324
+ [csrfHeaderName]: JSON.stringify({
325
+ token: userIdResult.csrfToken,
326
+ expiration,
327
+ }),
328
+ };
328
329
  } else {
329
330
  return undefined;
330
331
  }
@@ -332,18 +333,18 @@ export class BackendAuthClient<
332
333
 
333
334
  /** Reads the user's assumed user headers and, if configured, gets the assumed user. */
334
335
  protected async getAssumedUser({
335
- headers,
336
+ requestHeaders,
336
337
  user,
337
338
  }: {
338
339
  user: DatabaseUser;
339
- headers: IncomingHttpHeaders;
340
+ requestHeaders: IncomingHttpHeaders;
340
341
  }): Promise<DatabaseUser | undefined> {
341
342
  if (!this.config.assumeUser || !(await this.config.assumeUser.canAssumeUser(user))) {
342
343
  return undefined;
343
344
  }
344
345
 
345
346
  const assumedUserHeader: string | undefined = ensureArray(
346
- headers[this.config.overrides?.assumedUserHeaderName || AuthHeaderName.AssumedUser],
347
+ requestHeaders[this.config.assumedUserHeaderName || AuthHeaderName.AssumedUser],
347
348
  )[0];
348
349
 
349
350
  if (!assumedUserHeader) {
@@ -361,6 +362,7 @@ export class BackendAuthClient<
361
362
  isSignUpCookie: false,
362
363
  userId: parsedAssumedUserData.userId,
363
364
  assumingUser: parsedAssumedUserData.assumedUserParams,
365
+ requestHeaders,
364
366
  });
365
367
 
366
368
  return assumedUser;
@@ -384,13 +386,10 @@ export class BackendAuthClient<
384
386
  const userIdResult = await extractUserIdFromRequestHeaders<UserId>(
385
387
  requestHeaders,
386
388
  await this.getJwtParams(),
389
+ this.config.csrf,
387
390
  isSignUpCookie ? AuthCookieName.SignUp : AuthCookieName.Auth,
388
- this.config.overrides,
389
391
  );
390
392
  if (!userIdResult) {
391
- if (!isSignUpCookie) {
392
- authLog('auth-vir: getSecureUser failed - could not extract user from request');
393
- }
394
393
  return undefined;
395
394
  }
396
395
 
@@ -398,17 +397,15 @@ export class BackendAuthClient<
398
397
  userId: userIdResult.userId,
399
398
  assumingUser: undefined,
400
399
  isSignUpCookie,
400
+ requestHeaders,
401
401
  });
402
402
 
403
403
  if (!user) {
404
- authLog('auth-vir: getSecureUser failed - user not found in database', {
405
- userId: userIdResult.userId,
406
- });
407
404
  return undefined;
408
405
  }
409
406
 
410
407
  const assumedUser = await this.getAssumedUser({
411
- headers: requestHeaders,
408
+ requestHeaders,
412
409
  user,
413
410
  });
414
411
 
@@ -429,13 +426,13 @@ export class BackendAuthClient<
429
426
  * Get all the JWT params used when creating the auth cookie, in case you need them for
430
427
  * something else too.
431
428
  */
432
- public async getJwtParams(): Promise<Readonly<CreateJwtParams>> {
429
+ public async getJwtParams(): Promise<Readonly<CreateJwtParams> & ParseJwtParams> {
433
430
  const rawJwtKeys = await this.config.getJwtKeys();
434
431
 
435
432
  const cacheKey = JSON.stringify(rawJwtKeys);
436
433
 
437
434
  const cachedParsedKeys = this.cachedParsedJwtKeys[cacheKey];
438
- const parsedKeys = cachedParsedKeys ?? (await parseJwtKeys(rawJwtKeys));
435
+ const parsedKeys = cachedParsedKeys || (await parseJwtKeys(rawJwtKeys));
439
436
 
440
437
  if (!cachedParsedKeys) {
441
438
  this.cachedParsedJwtKeys = {[cacheKey]: parsedKeys};
@@ -445,6 +442,7 @@ export class BackendAuthClient<
445
442
  audience: 'server-context',
446
443
  issuer: 'server-auth',
447
444
  jwtDuration: this.config.userSessionIdleTimeout || defaultSessionIdleTimeout,
445
+ allowedClockSkew: this.config.allowedClockSkew || defaultAllowedClockSkew,
448
446
  };
449
447
  }
450
448
 
@@ -460,37 +458,29 @@ export class BackendAuthClient<
460
458
  }
461
459
  >,
462
460
  ): Promise<
463
- Partial<Record<CsrfHeaderName, string>> & {
461
+ Record<string, string | string[]> & {
464
462
  'set-cookie': string[];
465
463
  }
466
464
  > {
467
- authLog(
468
- 'auth-vir: LOGOUT - BackendAuthClient.createLogoutHeaders called',
469
- {
470
- allCookies: 'allCookies' in params ? params.allCookies : undefined,
471
- isSignUpCookie: 'isSignUpCookie' in params ? params.isSignUpCookie : undefined,
472
- },
473
- new Error().stack,
474
- );
475
465
  const signUpCookieHeaders =
476
466
  params.allCookies || params.isSignUpCookie
477
- ? (generateLogoutHeaders(
467
+ ? generateLogoutHeaders(
478
468
  await this.getCookieParams({
479
469
  isSignUpCookie: true,
480
470
  requestHeaders: undefined,
481
471
  }),
482
- this.config.overrides,
483
- ) satisfies Record<CsrfHeaderName, string>)
472
+ this.config.csrf,
473
+ )
484
474
  : undefined;
485
475
  const authCookieHeaders =
486
476
  params.allCookies || !params.isSignUpCookie
487
- ? (generateLogoutHeaders(
477
+ ? generateLogoutHeaders(
488
478
  await this.getCookieParams({
489
479
  isSignUpCookie: false,
490
480
  requestHeaders: undefined,
491
481
  }),
492
- this.config.overrides,
493
- ) satisfies Record<CsrfHeaderName, string>)
482
+ this.config.csrf,
483
+ )
494
484
  : undefined;
495
485
 
496
486
  const setCookieHeader: {
@@ -504,7 +494,7 @@ export class BackendAuthClient<
504
494
  const csrfTokenHeader = {
505
495
  ...authCookieHeaders,
506
496
  ...signUpCookieHeaders,
507
- } as Record<CsrfHeaderName, string>;
497
+ };
508
498
 
509
499
  return {
510
500
  ...csrfTokenHeader,
@@ -531,15 +521,15 @@ export class BackendAuthClient<
531
521
  isSignUpCookie: !isSignUpCookie,
532
522
  requestHeaders,
533
523
  }),
534
- this.config.overrides,
524
+ this.config.csrf,
535
525
  )
536
526
  : undefined;
537
527
 
538
528
  const existingUserIdResult = await extractUserIdFromRequestHeaders<UserId>(
539
529
  requestHeaders,
540
530
  await this.getJwtParams(),
531
+ this.config.csrf,
541
532
  isSignUpCookie ? AuthCookieName.SignUp : AuthCookieName.Auth,
542
- this.config.overrides,
543
533
  );
544
534
  const sessionStartedAt = existingUserIdResult?.sessionStartedAt;
545
535
 
@@ -549,7 +539,7 @@ export class BackendAuthClient<
549
539
  isSignUpCookie,
550
540
  requestHeaders,
551
541
  }),
552
- this.config.overrides,
542
+ this.config.csrf,
553
543
  sessionStartedAt,
554
544
  );
555
545
 
@@ -628,7 +618,6 @@ export class BackendAuthClient<
628
618
  );
629
619
 
630
620
  if (!userIdResult) {
631
- authLog('auth-vir: getInsecureUser failed - could not extract user from request');
632
621
  return undefined;
633
622
  }
634
623
 
@@ -636,12 +625,10 @@ export class BackendAuthClient<
636
625
  isSignUpCookie: false,
637
626
  userId: userIdResult.userId,
638
627
  assumingUser: undefined,
628
+ requestHeaders,
639
629
  });
640
630
 
641
631
  if (!user) {
642
- authLog('auth-vir: getInsecureUser failed - user not found in database', {
643
- userId: userIdResult.userId,
644
- });
645
632
  return undefined;
646
633
  }
647
634