auth-vir 5.0.3 → 5.1.0

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.
@@ -137,6 +137,19 @@ export type BackendAuthClientConfig<DatabaseUser extends AnyObject, UserId exten
137
137
  * @default {minutes: 5}
138
138
  */
139
139
  allowedClockSkew: Readonly<AnyDuration>;
140
+ /**
141
+ * Optional separate origin for the CSRF cookie's `Domain` attribute. When set, the
142
+ * non-`HttpOnly` CSRF cookie will use this origin's hostname instead of `serviceOrigin`.
143
+ *
144
+ * This is useful when the backend and frontend live on different subdomains that don't
145
+ * share a common parent narrower than the top-level domain. The `HttpOnly` auth cookie
146
+ * stays scoped to `serviceOrigin` (protecting it from unrelated subdomains), while the CSRF
147
+ * cookie uses the broader domain so frontend JavaScript can read it via `document.cookie`.
148
+ *
149
+ * The CSRF token alone is not a security risk — it is only meaningful when paired with the
150
+ * JWT embedded in the `HttpOnly` auth cookie.
151
+ */
152
+ csrfCookieOrigin: string;
140
153
  }>>;
141
154
  /**
142
155
  * An auth client for creating and validating JWTs embedded in cookies. This should only be used in
@@ -149,6 +162,11 @@ export declare class BackendAuthClient<DatabaseUser extends AnyObject, UserId ex
149
162
  protected readonly config: BackendAuthClientConfig<DatabaseUser, UserId, AssumedUserParams>;
150
163
  protected cachedParsedJwtKeys: Record<string, Readonly<JwtKeys>>;
151
164
  constructor(config: BackendAuthClientConfig<DatabaseUser, UserId, AssumedUserParams>);
165
+ /**
166
+ * Resolves the origin to use for CSRF cookie generation. Returns `csrfCookieOrigin` if
167
+ * configured, otherwise falls back to the auth cookie origin.
168
+ */
169
+ protected resolveCsrfCookieOrigin(authCookieOrigin: string): string;
152
170
  /** Conditionally logs a message if logging is enabled for the given user context. */
153
171
  protected logForUser(params: {
154
172
  user: DatabaseUser | undefined;
@@ -218,6 +236,8 @@ export declare class BackendAuthClient<DatabaseUser extends AnyObject, UserId ex
218
236
  cookieParams: Readonly<CookieParams>;
219
237
  existingUserIdResult: Readonly<UserIdResult<UserId>>;
220
238
  }): Promise<Record<string, string | string[]>>;
239
+ /** Generates login headers for a brand-new session (no existing JWT to reuse). */
240
+ protected generateFreshLoginHeaders(userId: UserId, cookieParams: Readonly<CookieParams>): Promise<Record<string, string[]>>;
221
241
  /** Use these headers to log a user in. */
222
242
  createLoginHeaders({ userId, requestHeaders, isSignUpCookie, }: {
223
243
  userId: UserId;
@@ -1,7 +1,8 @@
1
1
  import { ensureArray, } from '@augment-vir/common';
2
2
  import { calculateRelativeDate, createUtcFullDate, getNowInUtcTimezone, isDateAfter, } from 'date-vir';
3
- import { extractUserIdFromRequestHeaders, generateLogoutHeaders, generateSuccessfulLoginHeaders, insecureExtractUserIdFromCookieAlone, } from '../auth.js';
4
- import { AuthCookie, generateAuthCookie, generateCsrfCookie } from '../cookie.js';
3
+ import { extractUserIdFromRequestHeaders, generateLogoutHeaders, insecureExtractUserIdFromCookieAlone, } from '../auth.js';
4
+ import { AuthCookie, clearCsrfCookie, generateAuthCookie, generateCsrfCookie, } from '../cookie.js';
5
+ import { generateCsrfToken } from '../csrf-token.js';
5
6
  import { AuthHeaderName, mergeHeaderValues } from '../headers.js';
6
7
  import { parseJwtKeys } from '../jwt/jwt-keys.js';
7
8
  import { defaultAllowedClockSkew } from '../jwt/jwt.js';
@@ -28,6 +29,13 @@ export class BackendAuthClient {
28
29
  constructor(config) {
29
30
  this.config = config;
30
31
  }
32
+ /**
33
+ * Resolves the origin to use for CSRF cookie generation. Returns `csrfCookieOrigin` if
34
+ * configured, otherwise falls back to the auth cookie origin.
35
+ */
36
+ resolveCsrfCookieOrigin(authCookieOrigin) {
37
+ return this.config.csrfCookieOrigin || authCookieOrigin;
38
+ }
31
39
  /** Conditionally logs a message if logging is enabled for the given user context. */
32
40
  logForUser(params, message, extra) {
33
41
  if (this.config.enableLogging?.(params)) {
@@ -143,7 +151,10 @@ export class BackendAuthClient {
143
151
  userId: userIdResult.userId,
144
152
  sessionStartedAt: userIdResult.sessionStartedAt || Date.now(),
145
153
  }, cookieParams);
146
- const csrfCookie = generateCsrfCookie(userIdResult.csrfToken, cookieParams);
154
+ const csrfCookie = generateCsrfCookie(userIdResult.csrfToken, {
155
+ ...cookieParams,
156
+ hostOrigin: this.resolveCsrfCookieOrigin(cookieParams.hostOrigin),
157
+ });
147
158
  return {
148
159
  'set-cookie': [
149
160
  authCookie,
@@ -227,10 +238,11 @@ export class BackendAuthClient {
227
238
  * Always include the CSRF cookie so it gets re-established if the browser clears it. When
228
239
  * session refresh fires, its headers already include a CSRF cookie.
229
240
  */
241
+ const authCookieOrigin = (await this.config.generateServiceOrigin?.({
242
+ requestHeaders,
243
+ })) || this.config.serviceOrigin;
230
244
  const csrfCookie = generateCsrfCookie(userIdResult.csrfToken, {
231
- hostOrigin: (await this.config.generateServiceOrigin?.({
232
- requestHeaders,
233
- })) || this.config.serviceOrigin,
245
+ hostOrigin: this.resolveCsrfCookieOrigin(authCookieOrigin),
234
246
  isDev: this.config.isDev,
235
247
  });
236
248
  return {
@@ -282,8 +294,18 @@ export class BackendAuthClient {
282
294
  preserveCsrf: !clearingAllCookies,
283
295
  })
284
296
  : undefined;
297
+ /**
298
+ * When `csrfCookieOrigin` is configured, the CSRF cookie lives on a broader domain than the
299
+ * auth cookie. Clear it on that broader domain too so stale tokens don't linger.
300
+ */
301
+ const broadCsrfClearCookie = clearingAllCookies && this.config.csrfCookieOrigin
302
+ ? clearCsrfCookie({
303
+ hostOrigin: this.config.csrfCookieOrigin,
304
+ isDev: this.config.isDev,
305
+ })
306
+ : undefined;
285
307
  return {
286
- 'set-cookie': mergeHeaderValues(signUpCookieHeaders?.['set-cookie'], authCookieHeaders?.['set-cookie']),
308
+ 'set-cookie': mergeHeaderValues(signUpCookieHeaders?.['set-cookie'], authCookieHeaders?.['set-cookie'], broadCsrfClearCookie),
287
309
  };
288
310
  }
289
311
  /**
@@ -296,7 +318,29 @@ export class BackendAuthClient {
296
318
  userId,
297
319
  sessionStartedAt: existingUserIdResult.sessionStartedAt,
298
320
  }, cookieParams);
299
- const csrfCookie = generateCsrfCookie(existingUserIdResult.csrfToken, cookieParams);
321
+ const csrfCookie = generateCsrfCookie(existingUserIdResult.csrfToken, {
322
+ ...cookieParams,
323
+ hostOrigin: this.resolveCsrfCookieOrigin(cookieParams.hostOrigin),
324
+ });
325
+ return {
326
+ 'set-cookie': [
327
+ authCookie,
328
+ csrfCookie,
329
+ ],
330
+ };
331
+ }
332
+ /** Generates login headers for a brand-new session (no existing JWT to reuse). */
333
+ async generateFreshLoginHeaders(userId, cookieParams) {
334
+ const csrfToken = generateCsrfToken();
335
+ const authCookie = await generateAuthCookie({
336
+ csrfToken,
337
+ userId,
338
+ sessionStartedAt: Date.now(),
339
+ }, cookieParams);
340
+ const csrfCookie = generateCsrfCookie(csrfToken, {
341
+ ...cookieParams,
342
+ hostOrigin: this.resolveCsrfCookieOrigin(cookieParams.hostOrigin),
343
+ });
300
344
  return {
301
345
  'set-cookie': [
302
346
  authCookie,
@@ -312,7 +356,9 @@ export class BackendAuthClient {
312
356
  ? generateLogoutHeaders(await this.getCookieParams({
313
357
  isSignUpCookie: !isSignUpCookie,
314
358
  requestHeaders,
315
- }))
359
+ }), {
360
+ preserveCsrf: true,
361
+ })
316
362
  : undefined;
317
363
  const existingUserIdResult = await extractUserIdFromRequestHeaders(requestHeaders, await this.getJwtParams(), this.config.csrf, isSignUpCookie ? AuthCookie.SignUp : AuthCookie.Auth);
318
364
  const cookieParams = await this.getCookieParams({
@@ -325,7 +371,7 @@ export class BackendAuthClient {
325
371
  cookieParams,
326
372
  existingUserIdResult,
327
373
  })
328
- : await generateSuccessfulLoginHeaders(userId, cookieParams);
374
+ : await this.generateFreshLoginHeaders(userId, cookieParams);
329
375
  return {
330
376
  ...newCookieHeaders,
331
377
  'set-cookie': mergeHeaderValues(newCookieHeaders['set-cookie'], discardOppositeCookieHeaders?.['set-cookie']),
@@ -22,8 +22,8 @@ const config = {
22
22
  "fromEnvVar": null
23
23
  },
24
24
  "config": {
25
- "moduleFormat": "esm",
26
- "engineType": "client"
25
+ "engineType": "client",
26
+ "moduleFormat": "esm"
27
27
  },
28
28
  "binaryTargets": [
29
29
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auth-vir",
3
- "version": "5.0.3",
3
+ "version": "5.1.0",
4
4
  "description": "Auth made easy and secure via JWT cookies, CSRF tokens, and password hashing helpers.",
5
5
  "keywords": [
6
6
  "auth",
@@ -17,12 +17,17 @@ import {type EmptyObject, type RequireExactlyOne, type RequireOneOrNone} from 't
17
17
  import {
18
18
  extractUserIdFromRequestHeaders,
19
19
  generateLogoutHeaders,
20
- generateSuccessfulLoginHeaders,
21
20
  insecureExtractUserIdFromCookieAlone,
22
21
  type UserIdResult,
23
22
  } from '../auth.js';
24
- import {AuthCookie, generateAuthCookie, generateCsrfCookie, type CookieParams} from '../cookie.js';
25
- import {type CsrfHeaderNameOption} from '../csrf-token.js';
23
+ import {
24
+ AuthCookie,
25
+ clearCsrfCookie,
26
+ generateAuthCookie,
27
+ generateCsrfCookie,
28
+ type CookieParams,
29
+ } from '../cookie.js';
30
+ import {generateCsrfToken, type CsrfHeaderNameOption} from '../csrf-token.js';
26
31
  import {AuthHeaderName, mergeHeaderValues} from '../headers.js';
27
32
  import {generateNewJwtKeys, parseJwtKeys, type JwtKeys, type RawJwtKeys} from '../jwt/jwt-keys.js';
28
33
  import {defaultAllowedClockSkew, type CreateJwtParams, type ParseJwtParams} from '../jwt/jwt.js';
@@ -168,6 +173,19 @@ export type BackendAuthClientConfig<
168
173
  * @default {minutes: 5}
169
174
  */
170
175
  allowedClockSkew: Readonly<AnyDuration>;
176
+ /**
177
+ * Optional separate origin for the CSRF cookie's `Domain` attribute. When set, the
178
+ * non-`HttpOnly` CSRF cookie will use this origin's hostname instead of `serviceOrigin`.
179
+ *
180
+ * This is useful when the backend and frontend live on different subdomains that don't
181
+ * share a common parent narrower than the top-level domain. The `HttpOnly` auth cookie
182
+ * stays scoped to `serviceOrigin` (protecting it from unrelated subdomains), while the CSRF
183
+ * cookie uses the broader domain so frontend JavaScript can read it via `document.cookie`.
184
+ *
185
+ * The CSRF token alone is not a security risk — it is only meaningful when paired with the
186
+ * JWT embedded in the `HttpOnly` auth cookie.
187
+ */
188
+ csrfCookieOrigin: string;
171
189
  }>
172
190
  >;
173
191
 
@@ -201,6 +219,14 @@ export class BackendAuthClient<
201
219
  protected readonly config: BackendAuthClientConfig<DatabaseUser, UserId, AssumedUserParams>,
202
220
  ) {}
203
221
 
222
+ /**
223
+ * Resolves the origin to use for CSRF cookie generation. Returns `csrfCookieOrigin` if
224
+ * configured, otherwise falls back to the auth cookie origin.
225
+ */
226
+ protected resolveCsrfCookieOrigin(authCookieOrigin: string): string {
227
+ return this.config.csrfCookieOrigin || authCookieOrigin;
228
+ }
229
+
204
230
  /** Conditionally logs a message if logging is enabled for the given user context. */
205
231
  protected logForUser(
206
232
  params: {
@@ -383,7 +409,10 @@ export class BackendAuthClient<
383
409
  },
384
410
  cookieParams,
385
411
  );
386
- const csrfCookie = generateCsrfCookie(userIdResult.csrfToken, cookieParams);
412
+ const csrfCookie = generateCsrfCookie(userIdResult.csrfToken, {
413
+ ...cookieParams,
414
+ hostOrigin: this.resolveCsrfCookieOrigin(cookieParams.hostOrigin),
415
+ });
387
416
 
388
417
  return {
389
418
  'set-cookie': [
@@ -519,11 +548,13 @@ export class BackendAuthClient<
519
548
  * Always include the CSRF cookie so it gets re-established if the browser clears it. When
520
549
  * session refresh fires, its headers already include a CSRF cookie.
521
550
  */
551
+ const authCookieOrigin =
552
+ (await this.config.generateServiceOrigin?.({
553
+ requestHeaders,
554
+ })) || this.config.serviceOrigin;
555
+
522
556
  const csrfCookie = generateCsrfCookie(userIdResult.csrfToken, {
523
- hostOrigin:
524
- (await this.config.generateServiceOrigin?.({
525
- requestHeaders,
526
- })) || this.config.serviceOrigin,
557
+ hostOrigin: this.resolveCsrfCookieOrigin(authCookieOrigin),
527
558
  isDev: this.config.isDev,
528
559
  });
529
560
 
@@ -605,10 +636,23 @@ export class BackendAuthClient<
605
636
  )
606
637
  : undefined;
607
638
 
639
+ /**
640
+ * When `csrfCookieOrigin` is configured, the CSRF cookie lives on a broader domain than the
641
+ * auth cookie. Clear it on that broader domain too so stale tokens don't linger.
642
+ */
643
+ const broadCsrfClearCookie =
644
+ clearingAllCookies && this.config.csrfCookieOrigin
645
+ ? clearCsrfCookie({
646
+ hostOrigin: this.config.csrfCookieOrigin,
647
+ isDev: this.config.isDev,
648
+ })
649
+ : undefined;
650
+
608
651
  return {
609
652
  'set-cookie': mergeHeaderValues(
610
653
  signUpCookieHeaders?.['set-cookie'],
611
654
  authCookieHeaders?.['set-cookie'],
655
+ broadCsrfClearCookie,
612
656
  ),
613
657
  };
614
658
  }
@@ -635,7 +679,39 @@ export class BackendAuthClient<
635
679
  cookieParams,
636
680
  );
637
681
 
638
- const csrfCookie = generateCsrfCookie(existingUserIdResult.csrfToken, cookieParams);
682
+ const csrfCookie = generateCsrfCookie(existingUserIdResult.csrfToken, {
683
+ ...cookieParams,
684
+ hostOrigin: this.resolveCsrfCookieOrigin(cookieParams.hostOrigin),
685
+ });
686
+
687
+ return {
688
+ 'set-cookie': [
689
+ authCookie,
690
+ csrfCookie,
691
+ ],
692
+ };
693
+ }
694
+
695
+ /** Generates login headers for a brand-new session (no existing JWT to reuse). */
696
+ protected async generateFreshLoginHeaders(
697
+ userId: UserId,
698
+ cookieParams: Readonly<CookieParams>,
699
+ ): Promise<Record<string, string[]>> {
700
+ const csrfToken = generateCsrfToken();
701
+
702
+ const authCookie = await generateAuthCookie(
703
+ {
704
+ csrfToken,
705
+ userId,
706
+ sessionStartedAt: Date.now(),
707
+ },
708
+ cookieParams,
709
+ );
710
+
711
+ const csrfCookie = generateCsrfCookie(csrfToken, {
712
+ ...cookieParams,
713
+ hostOrigin: this.resolveCsrfCookieOrigin(cookieParams.hostOrigin),
714
+ });
639
715
 
640
716
  return {
641
717
  'set-cookie': [
@@ -664,6 +740,9 @@ export class BackendAuthClient<
664
740
  isSignUpCookie: !isSignUpCookie,
665
741
  requestHeaders,
666
742
  }),
743
+ {
744
+ preserveCsrf: true,
745
+ },
667
746
  )
668
747
  : undefined;
669
748
 
@@ -685,7 +764,7 @@ export class BackendAuthClient<
685
764
  cookieParams,
686
765
  existingUserIdResult,
687
766
  })
688
- : await generateSuccessfulLoginHeaders(userId, cookieParams);
767
+ : await this.generateFreshLoginHeaders(userId, cookieParams);
689
768
 
690
769
  return {
691
770
  ...newCookieHeaders,
@@ -27,8 +27,8 @@ const config: runtime.GetPrismaClientConfig = {
27
27
  "fromEnvVar": null
28
28
  },
29
29
  "config": {
30
- "moduleFormat": "esm",
31
- "engineType": "client"
30
+ "engineType": "client",
31
+ "moduleFormat": "esm"
32
32
  },
33
33
  "binaryTargets": [
34
34
  {