auth-vir 5.0.4 → 5.2.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.
package/README.md CHANGED
@@ -156,12 +156,12 @@ export async function createUser(
156
156
  */
157
157
  export async function getAuthenticatedUser(request: ClientRequest) {
158
158
  const userId = (
159
- await extractUserIdFromRequestHeaders<MyUserId>(
160
- request.getHeaders(),
159
+ await extractUserIdFromRequestHeaders<MyUserId>({
160
+ headers: request.getHeaders(),
161
161
  jwtParams,
162
- csrfOption,
163
- AuthCookie.Auth,
164
- )
162
+ csrfHeaderNameOption: csrfOption,
163
+ cookieName: AuthCookie.Auth,
164
+ })
165
165
  )?.userId;
166
166
  const user = userId ? findUserInDatabaseById(userId) : undefined;
167
167
 
@@ -137,6 +137,25 @@ 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;
153
+ /**
154
+ * Optional suffix appended to cookie names (e.g., `'staging'` produces `auth-staging`,
155
+ * `auth-vir-csrf-staging`). When `undefined`, cookie names are unchanged. Useful for
156
+ * running multiple environments on the same domain without cookie collisions.
157
+ */
158
+ cookieNameSuffix: string;
140
159
  }>>;
141
160
  /**
142
161
  * An auth client for creating and validating JWTs embedded in cookies. This should only be used in
@@ -149,6 +168,11 @@ export declare class BackendAuthClient<DatabaseUser extends AnyObject, UserId ex
149
168
  protected readonly config: BackendAuthClientConfig<DatabaseUser, UserId, AssumedUserParams>;
150
169
  protected cachedParsedJwtKeys: Record<string, Readonly<JwtKeys>>;
151
170
  constructor(config: BackendAuthClientConfig<DatabaseUser, UserId, AssumedUserParams>);
171
+ /**
172
+ * Resolves the origin to use for CSRF cookie generation. Returns `csrfCookieOrigin` if
173
+ * configured, otherwise falls back to the auth cookie origin.
174
+ */
175
+ protected resolveCsrfCookieOrigin(authCookieOrigin: string): string;
152
176
  /** Conditionally logs a message if logging is enabled for the given user context. */
153
177
  protected logForUser(params: {
154
178
  user: DatabaseUser | undefined;
@@ -218,6 +242,8 @@ export declare class BackendAuthClient<DatabaseUser extends AnyObject, UserId ex
218
242
  cookieParams: Readonly<CookieParams>;
219
243
  existingUserIdResult: Readonly<UserIdResult<UserId>>;
220
244
  }): Promise<Record<string, string | string[]>>;
245
+ /** Generates login headers for a brand-new session (no existing JWT to reuse). */
246
+ protected generateFreshLoginHeaders(userId: UserId, cookieParams: Readonly<CookieParams>): Promise<Record<string, string[]>>;
221
247
  /** Use these headers to log a user in. */
222
248
  createLoginHeaders({ userId, requestHeaders, isSignUpCookie, }: {
223
249
  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, resolveCookieName, } 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)) {
@@ -56,6 +64,7 @@ export class BackendAuthClient {
56
64
  jwtParams: await this.getJwtParams(),
57
65
  isDev: this.config.isDev,
58
66
  authCookie: isSignUpCookie ? AuthCookie.SignUp : AuthCookie.Auth,
67
+ cookieNameSuffix: this.config.cookieNameSuffix,
59
68
  };
60
69
  }
61
70
  /** Calls the provided `getUserFromDatabase` config. */
@@ -143,7 +152,11 @@ export class BackendAuthClient {
143
152
  userId: userIdResult.userId,
144
153
  sessionStartedAt: userIdResult.sessionStartedAt || Date.now(),
145
154
  }, cookieParams);
146
- const csrfCookie = generateCsrfCookie(userIdResult.csrfToken, cookieParams);
155
+ const csrfCookie = generateCsrfCookie(userIdResult.csrfToken, {
156
+ ...cookieParams,
157
+ hostOrigin: this.resolveCsrfCookieOrigin(cookieParams.hostOrigin),
158
+ cookieNameSuffix: this.config.cookieNameSuffix,
159
+ });
147
160
  return {
148
161
  'set-cookie': [
149
162
  authCookie,
@@ -186,7 +199,13 @@ export class BackendAuthClient {
186
199
  }
187
200
  /** Securely extract a user from their request headers. */
188
201
  async getSecureUser({ requestHeaders, isSignUpCookie, allowUserAuthRefresh, }) {
189
- const userIdResult = await extractUserIdFromRequestHeaders(requestHeaders, await this.getJwtParams(), this.config.csrf, isSignUpCookie ? AuthCookie.SignUp : AuthCookie.Auth);
202
+ const userIdResult = await extractUserIdFromRequestHeaders({
203
+ headers: requestHeaders,
204
+ jwtParams: await this.getJwtParams(),
205
+ csrfHeaderNameOption: this.config.csrf,
206
+ cookieName: isSignUpCookie ? AuthCookie.SignUp : AuthCookie.Auth,
207
+ cookieNameSuffix: this.config.cookieNameSuffix,
208
+ });
190
209
  if (!userIdResult) {
191
210
  this.logForUser({
192
211
  user: undefined,
@@ -227,11 +246,13 @@ export class BackendAuthClient {
227
246
  * Always include the CSRF cookie so it gets re-established if the browser clears it. When
228
247
  * session refresh fires, its headers already include a CSRF cookie.
229
248
  */
249
+ const authCookieOrigin = (await this.config.generateServiceOrigin?.({
250
+ requestHeaders,
251
+ })) || this.config.serviceOrigin;
230
252
  const csrfCookie = generateCsrfCookie(userIdResult.csrfToken, {
231
- hostOrigin: (await this.config.generateServiceOrigin?.({
232
- requestHeaders,
233
- })) || this.config.serviceOrigin,
253
+ hostOrigin: this.resolveCsrfCookieOrigin(authCookieOrigin),
234
254
  isDev: this.config.isDev,
255
+ cookieNameSuffix: this.config.cookieNameSuffix,
235
256
  });
236
257
  return {
237
258
  user: assumedUser || user,
@@ -282,8 +303,19 @@ export class BackendAuthClient {
282
303
  preserveCsrf: !clearingAllCookies,
283
304
  })
284
305
  : undefined;
306
+ /**
307
+ * When `csrfCookieOrigin` is configured, the CSRF cookie lives on a broader domain than the
308
+ * auth cookie. Clear it on that broader domain too so stale tokens don't linger.
309
+ */
310
+ const broadCsrfClearCookie = clearingAllCookies && this.config.csrfCookieOrigin
311
+ ? clearCsrfCookie({
312
+ hostOrigin: this.config.csrfCookieOrigin,
313
+ isDev: this.config.isDev,
314
+ cookieNameSuffix: this.config.cookieNameSuffix,
315
+ })
316
+ : undefined;
285
317
  return {
286
- 'set-cookie': mergeHeaderValues(signUpCookieHeaders?.['set-cookie'], authCookieHeaders?.['set-cookie']),
318
+ 'set-cookie': mergeHeaderValues(signUpCookieHeaders?.['set-cookie'], authCookieHeaders?.['set-cookie'], broadCsrfClearCookie),
287
319
  };
288
320
  }
289
321
  /**
@@ -296,7 +328,31 @@ export class BackendAuthClient {
296
328
  userId,
297
329
  sessionStartedAt: existingUserIdResult.sessionStartedAt,
298
330
  }, cookieParams);
299
- const csrfCookie = generateCsrfCookie(existingUserIdResult.csrfToken, cookieParams);
331
+ const csrfCookie = generateCsrfCookie(existingUserIdResult.csrfToken, {
332
+ ...cookieParams,
333
+ hostOrigin: this.resolveCsrfCookieOrigin(cookieParams.hostOrigin),
334
+ cookieNameSuffix: this.config.cookieNameSuffix,
335
+ });
336
+ return {
337
+ 'set-cookie': [
338
+ authCookie,
339
+ csrfCookie,
340
+ ],
341
+ };
342
+ }
343
+ /** Generates login headers for a brand-new session (no existing JWT to reuse). */
344
+ async generateFreshLoginHeaders(userId, cookieParams) {
345
+ const csrfToken = generateCsrfToken();
346
+ const authCookie = await generateAuthCookie({
347
+ csrfToken,
348
+ userId,
349
+ sessionStartedAt: Date.now(),
350
+ }, cookieParams);
351
+ const csrfCookie = generateCsrfCookie(csrfToken, {
352
+ ...cookieParams,
353
+ hostOrigin: this.resolveCsrfCookieOrigin(cookieParams.hostOrigin),
354
+ cookieNameSuffix: this.config.cookieNameSuffix,
355
+ });
300
356
  return {
301
357
  'set-cookie': [
302
358
  authCookie,
@@ -307,7 +363,8 @@ export class BackendAuthClient {
307
363
  /** Use these headers to log a user in. */
308
364
  async createLoginHeaders({ userId, requestHeaders, isSignUpCookie, }) {
309
365
  const oppositeCookieName = isSignUpCookie ? AuthCookie.Auth : AuthCookie.SignUp;
310
- const hasExistingOppositeCookie = requestHeaders.cookie?.includes(`${oppositeCookieName}=`);
366
+ const resolvedOppositeCookieName = resolveCookieName(oppositeCookieName, this.config.cookieNameSuffix);
367
+ const hasExistingOppositeCookie = requestHeaders.cookie?.includes(`${resolvedOppositeCookieName}=`);
311
368
  const discardOppositeCookieHeaders = hasExistingOppositeCookie
312
369
  ? generateLogoutHeaders(await this.getCookieParams({
313
370
  isSignUpCookie: !isSignUpCookie,
@@ -316,7 +373,13 @@ export class BackendAuthClient {
316
373
  preserveCsrf: true,
317
374
  })
318
375
  : undefined;
319
- const existingUserIdResult = await extractUserIdFromRequestHeaders(requestHeaders, await this.getJwtParams(), this.config.csrf, isSignUpCookie ? AuthCookie.SignUp : AuthCookie.Auth);
376
+ const existingUserIdResult = await extractUserIdFromRequestHeaders({
377
+ headers: requestHeaders,
378
+ jwtParams: await this.getJwtParams(),
379
+ csrfHeaderNameOption: this.config.csrf,
380
+ cookieName: isSignUpCookie ? AuthCookie.SignUp : AuthCookie.Auth,
381
+ cookieNameSuffix: this.config.cookieNameSuffix,
382
+ });
320
383
  const cookieParams = await this.getCookieParams({
321
384
  isSignUpCookie,
322
385
  requestHeaders,
@@ -327,7 +390,7 @@ export class BackendAuthClient {
327
390
  cookieParams,
328
391
  existingUserIdResult,
329
392
  })
330
- : await generateSuccessfulLoginHeaders(userId, cookieParams);
393
+ : await this.generateFreshLoginHeaders(userId, cookieParams);
331
394
  return {
332
395
  ...newCookieHeaders,
333
396
  'set-cookie': mergeHeaderValues(newCookieHeaders['set-cookie'], discardOppositeCookieHeaders?.['set-cookie']),
@@ -361,7 +424,12 @@ export class BackendAuthClient {
361
424
  */
362
425
  async getInsecureUser({ requestHeaders, allowUserAuthRefresh, }) {
363
426
  // eslint-disable-next-line @typescript-eslint/no-deprecated
364
- const userIdResult = await insecureExtractUserIdFromCookieAlone(requestHeaders, await this.getJwtParams(), AuthCookie.Auth);
427
+ const userIdResult = await insecureExtractUserIdFromCookieAlone({
428
+ headers: requestHeaders,
429
+ jwtParams: await this.getJwtParams(),
430
+ cookieName: AuthCookie.Auth,
431
+ cookieNameSuffix: this.config.cookieNameSuffix,
432
+ });
365
433
  if (!userIdResult) {
366
434
  this.logForUser({
367
435
  user: undefined,
@@ -10,6 +10,11 @@ import { type CsrfHeaderNameOption } from '../csrf-token.js';
10
10
  export type FrontendAuthClientConfig = Readonly<{
11
11
  csrf: Readonly<CsrfHeaderNameOption>;
12
12
  }> & PartialWithUndefined<{
13
+ /**
14
+ * Optional suffix appended to cookie names (e.g., `'staging'` produces
15
+ * `auth-vir-csrf-staging`). When `undefined`, cookie names are unchanged.
16
+ */
17
+ cookieNameSuffix: string;
13
18
  /**
14
19
  * Determine if the current user can assume the identity of another user. If this is not
15
20
  * defined, all users will be blocked from assuming other user identities.
@@ -79,7 +79,7 @@ export class FrontendAuthClient {
79
79
  * combine them with these.
80
80
  */
81
81
  createAuthenticatedRequestInit() {
82
- const csrfToken = getCurrentCsrfToken();
82
+ const csrfToken = getCurrentCsrfToken(this.config.cookieNameSuffix);
83
83
  const assumedUser = this.getAssumedUser();
84
84
  const headers = {
85
85
  ...(csrfToken
package/dist/auth.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { type SelectFrom } from '@augment-vir/common';
1
+ import { type PartialWithUndefined, type SelectFrom } from '@augment-vir/common';
2
2
  import { type FullDate, type UtcTimezone } from 'date-vir';
3
- import { AuthCookie, type CookieParams } from './cookie.js';
3
+ import { type AuthCookie, type CookieParams } from './cookie.js';
4
4
  import { type CsrfHeaderNameOption } from './csrf-token.js';
5
5
  import { type ParseJwtParams } from './jwt/jwt.js';
6
6
  import { type JwtUserData } from './jwt/user-jwt.js';
@@ -37,7 +37,13 @@ export type UserIdResult<UserId extends string | number> = {
37
37
  * @category Auth : Host
38
38
  * @returns The extracted user id or `undefined` if no valid auth headers exist.
39
39
  */
40
- export declare function extractUserIdFromRequestHeaders<UserId extends string | number>(headers: HeaderContainer, jwtParams: Readonly<ParseJwtParams>, csrfHeaderNameOption: Readonly<CsrfHeaderNameOption>, cookieName?: AuthCookie): Promise<Readonly<UserIdResult<UserId>> | undefined>;
40
+ export declare function extractUserIdFromRequestHeaders<UserId extends string | number>({ headers, jwtParams, csrfHeaderNameOption, cookieName, cookieNameSuffix, }: Readonly<{
41
+ headers: HeaderContainer;
42
+ jwtParams: Readonly<ParseJwtParams>;
43
+ csrfHeaderNameOption: Readonly<CsrfHeaderNameOption>;
44
+ cookieName: AuthCookie;
45
+ cookieNameSuffix?: string | undefined;
46
+ }>): Promise<Readonly<UserIdResult<UserId>> | undefined>;
41
47
  /**
42
48
  * Extract a user id from just the cookie, without CSRF token validation. This is _less secure_ than
43
49
  * {@link extractUserIdFromRequestHeaders} as a result. This should only be used in rare
@@ -46,7 +52,12 @@ export declare function extractUserIdFromRequestHeaders<UserId extends string |
46
52
  * @deprecated Prefer {@link extractUserIdFromRequestHeaders} instead: it is more secure.
47
53
  * @category Auth : Host
48
54
  */
49
- export declare function insecureExtractUserIdFromCookieAlone<UserId extends string | number>(headers: HeaderContainer, jwtParams: Readonly<ParseJwtParams>, cookieName: AuthCookie): Promise<Readonly<UserIdResult<UserId>> | undefined>;
55
+ export declare function insecureExtractUserIdFromCookieAlone<UserId extends string | number>({ headers, jwtParams, cookieName, cookieNameSuffix, }: Readonly<{
56
+ headers: HeaderContainer;
57
+ jwtParams: Readonly<ParseJwtParams>;
58
+ cookieName: AuthCookie;
59
+ cookieNameSuffix?: string | undefined;
60
+ }>): Promise<Readonly<UserIdResult<UserId>> | undefined>;
50
61
  /**
51
62
  * Used by host (backend) code to set headers on a response object. Sets both the auth JWT cookie
52
63
  * and the CSRF token cookie. The CSRF cookie is not `HttpOnly` so that frontend JavaScript can read
@@ -71,11 +82,13 @@ sessionStartedAt?: number | undefined): Promise<Record<string, string[]>>;
71
82
  export declare function generateLogoutHeaders(cookieConfig: Readonly<SelectFrom<CookieParams, {
72
83
  hostOrigin: true;
73
84
  isDev: true;
74
- }>>, options?: Readonly<{
85
+ }>> & PartialWithUndefined<{
86
+ cookieNameSuffix: string;
87
+ }>, options?: Readonly<PartialWithUndefined<{
75
88
  /**
76
- * When `true`, the CSRF cookie is preserved (not cleared). Use this when clearing only one
77
- * cookie type (e.g., the auth cookie) while keeping the other active session (e.g.,
89
+ * When `true`, the CSRF cookie is preserved (not cleared). Use this when clearing only
90
+ * one cookie type (e.g., the auth cookie) while keeping the other active session (e.g.,
78
91
  * sign-up) that still needs its CSRF token.
79
92
  */
80
- preserveCsrf?: boolean | undefined;
81
- }>): Record<string, string[]>;
93
+ preserveCsrf: boolean;
94
+ }>>): Record<string, string[]>;
package/dist/auth.js CHANGED
@@ -1,4 +1,4 @@
1
- import { AuthCookie, clearAuthCookie, clearCsrfCookie, extractCookieJwt, generateAuthCookie, generateCsrfCookie, } from './cookie.js';
1
+ import { clearAuthCookie, clearCsrfCookie, extractCookieJwt, generateAuthCookie, generateCsrfCookie, } from './cookie.js';
2
2
  import { generateCsrfToken, resolveCsrfHeaderName } from './csrf-token.js';
3
3
  function readHeader(headers, headerName) {
4
4
  if (headers instanceof Headers) {
@@ -28,14 +28,19 @@ function readCsrfTokenHeader(headers, csrfHeaderNameOption) {
28
28
  * @category Auth : Host
29
29
  * @returns The extracted user id or `undefined` if no valid auth headers exist.
30
30
  */
31
- export async function extractUserIdFromRequestHeaders(headers, jwtParams, csrfHeaderNameOption, cookieName = AuthCookie.Auth) {
31
+ export async function extractUserIdFromRequestHeaders({ headers, jwtParams, csrfHeaderNameOption, cookieName, cookieNameSuffix, }) {
32
32
  try {
33
33
  const csrfToken = readCsrfTokenHeader(headers, csrfHeaderNameOption);
34
34
  const cookie = readHeader(headers, 'cookie');
35
35
  if (!cookie || !csrfToken) {
36
36
  return undefined;
37
37
  }
38
- const jwt = await extractCookieJwt(cookie, jwtParams, cookieName);
38
+ const jwt = await extractCookieJwt({
39
+ rawCookie: cookie,
40
+ jwtParams,
41
+ cookieName,
42
+ cookieNameSuffix,
43
+ });
39
44
  if (!jwt || jwt.data.csrfToken !== csrfToken) {
40
45
  return undefined;
41
46
  }
@@ -60,13 +65,18 @@ export async function extractUserIdFromRequestHeaders(headers, jwtParams, csrfHe
60
65
  * @deprecated Prefer {@link extractUserIdFromRequestHeaders} instead: it is more secure.
61
66
  * @category Auth : Host
62
67
  */
63
- export async function insecureExtractUserIdFromCookieAlone(headers, jwtParams, cookieName) {
68
+ export async function insecureExtractUserIdFromCookieAlone({ headers, jwtParams, cookieName, cookieNameSuffix, }) {
64
69
  try {
65
70
  const cookie = readHeader(headers, 'cookie');
66
71
  if (!cookie) {
67
72
  return undefined;
68
73
  }
69
- const jwt = await extractCookieJwt(cookie, jwtParams, cookieName);
74
+ const jwt = await extractCookieJwt({
75
+ rawCookie: cookie,
76
+ jwtParams,
77
+ cookieName,
78
+ cookieNameSuffix,
79
+ });
70
80
  if (!jwt) {
71
81
  return undefined;
72
82
  }
package/dist/cookie.d.ts CHANGED
@@ -16,6 +16,13 @@ export declare enum AuthCookie {
16
16
  /** Used for storing the CSRF token. Not `HttpOnly` so that frontend JS can read it. */
17
17
  Csrf = "auth-vir-csrf"
18
18
  }
19
+ /**
20
+ * Resolves a cookie name by appending a suffix when provided. When `cookieNameSuffix` is
21
+ * `undefined`, the base name is returned unchanged.
22
+ *
23
+ * @category Internal
24
+ */
25
+ export declare function resolveCookieName(baseCookieName: AuthCookie, cookieNameSuffix?: string | undefined): string;
19
26
  /**
20
27
  * Parameters for {@link generateAuthCookie}.
21
28
  *
@@ -54,6 +61,12 @@ export type CookieParams = {
54
61
  * @default false
55
62
  */
56
63
  isDev: boolean;
64
+ /**
65
+ * Optional suffix appended to cookie names (e.g., `'staging'` produces `auth-staging`). When
66
+ * `undefined`, cookie names are unchanged. Useful for running multiple environments on the same
67
+ * domain without cookie collisions.
68
+ */
69
+ cookieNameSuffix: string;
57
70
  }>;
58
71
  /**
59
72
  * Generate a secure cookie that stores the user JWT data. Used in host (backend) code.
@@ -75,7 +88,9 @@ export declare function generateAuthCookie(userJwtData: Readonly<JwtUserData>, c
75
88
  export declare function generateCsrfCookie(csrfToken: string, cookieConfig: Readonly<SelectFrom<CookieParams, {
76
89
  hostOrigin: true;
77
90
  isDev: true;
78
- }>>): string;
91
+ }>> & PartialWithUndefined<{
92
+ cookieNameSuffix: string;
93
+ }>): string;
79
94
  /**
80
95
  * Generate a cookie value that will clear the previous auth cookie. Use this when signing out.
81
96
  *
@@ -86,6 +101,7 @@ export declare function clearAuthCookie(cookieConfig: Readonly<SelectFrom<Cookie
86
101
  isDev: true;
87
102
  }>> & PartialWithUndefined<{
88
103
  authCookie: AuthCookie;
104
+ cookieNameSuffix: string;
89
105
  }>): string;
90
106
  /**
91
107
  * Generate a cookie value that will clear the CSRF token cookie. Use this when signing out.
@@ -95,7 +111,9 @@ export declare function clearAuthCookie(cookieConfig: Readonly<SelectFrom<Cookie
95
111
  export declare function clearCsrfCookie(cookieConfig: Readonly<SelectFrom<CookieParams, {
96
112
  hostOrigin: true;
97
113
  isDev: true;
98
- }>>): string;
114
+ }>> & PartialWithUndefined<{
115
+ cookieNameSuffix: string;
116
+ }>): string;
99
117
  /**
100
118
  * Generate a cookie string from a raw set of parameters.
101
119
  *
@@ -108,4 +126,10 @@ export declare function generateCookie(params: Readonly<Record<string, Exclude<P
108
126
  * @category Internal
109
127
  * @returns The extracted auth Cookie JWT data or `undefined` if no valid auth JWT data was found.
110
128
  */
111
- export declare function extractCookieJwt(rawCookie: string, jwtParams: Readonly<ParseJwtParams>, cookieName: AuthCookie): Promise<undefined | ParsedJwt<JwtUserData>>;
129
+ export declare function extractCookieJwt({ rawCookie, jwtParams, cookieName, cookieNameSuffix, }: {
130
+ rawCookie: string;
131
+ jwtParams: Readonly<ParseJwtParams>;
132
+ cookieName: AuthCookie;
133
+ } & PartialWithUndefined<{
134
+ cookieNameSuffix: string;
135
+ }>): Promise<undefined | ParsedJwt<JwtUserData>>;
package/dist/cookie.js CHANGED
@@ -17,6 +17,20 @@ export var AuthCookie;
17
17
  /** Used for storing the CSRF token. Not `HttpOnly` so that frontend JS can read it. */
18
18
  AuthCookie["Csrf"] = "auth-vir-csrf";
19
19
  })(AuthCookie || (AuthCookie = {}));
20
+ /**
21
+ * Resolves a cookie name by appending a suffix when provided. When `cookieNameSuffix` is
22
+ * `undefined`, the base name is returned unchanged.
23
+ *
24
+ * @category Internal
25
+ */
26
+ export function resolveCookieName(baseCookieName, cookieNameSuffix) {
27
+ return [
28
+ baseCookieName,
29
+ cookieNameSuffix,
30
+ ]
31
+ .filter(check.isTruthy)
32
+ .join('-');
33
+ }
20
34
  function generateSetCookie({ name, value, httpOnly, cookieConfig, }) {
21
35
  return generateCookie({
22
36
  [name]: value,
@@ -39,7 +53,7 @@ function generateSetCookie({ name, value, httpOnly, cookieConfig, }) {
39
53
  */
40
54
  export async function generateAuthCookie(userJwtData, cookieConfig) {
41
55
  return generateSetCookie({
42
- name: cookieConfig.authCookie || AuthCookie.Auth,
56
+ name: resolveCookieName(cookieConfig.authCookie || AuthCookie.Auth, cookieConfig.cookieNameSuffix),
43
57
  value: await createUserJwt(userJwtData, cookieConfig.jwtParams),
44
58
  httpOnly: true,
45
59
  cookieConfig,
@@ -58,7 +72,7 @@ export async function generateAuthCookie(userJwtData, cookieConfig) {
58
72
  */
59
73
  export function generateCsrfCookie(csrfToken, cookieConfig) {
60
74
  return generateSetCookie({
61
- name: AuthCookie.Csrf,
75
+ name: resolveCookieName(AuthCookie.Csrf, cookieConfig.cookieNameSuffix),
62
76
  value: csrfToken,
63
77
  httpOnly: false,
64
78
  cookieConfig: {
@@ -76,7 +90,7 @@ export function generateCsrfCookie(csrfToken, cookieConfig) {
76
90
  */
77
91
  export function clearAuthCookie(cookieConfig) {
78
92
  return generateSetCookie({
79
- name: cookieConfig.authCookie || AuthCookie.Auth,
93
+ name: resolveCookieName(cookieConfig.authCookie || AuthCookie.Auth, cookieConfig.cookieNameSuffix),
80
94
  value: 'redacted',
81
95
  httpOnly: true,
82
96
  cookieConfig,
@@ -89,7 +103,7 @@ export function clearAuthCookie(cookieConfig) {
89
103
  */
90
104
  export function clearCsrfCookie(cookieConfig) {
91
105
  return generateSetCookie({
92
- name: AuthCookie.Csrf,
106
+ name: resolveCookieName(AuthCookie.Csrf, cookieConfig.cookieNameSuffix),
93
107
  value: 'redacted',
94
108
  httpOnly: false,
95
109
  cookieConfig,
@@ -125,13 +139,14 @@ export function generateCookie(params) {
125
139
  * @category Internal
126
140
  * @returns The extracted auth Cookie JWT data or `undefined` if no valid auth JWT data was found.
127
141
  */
128
- export async function extractCookieJwt(rawCookie, jwtParams, cookieName) {
129
- const cookieRegExp = new RegExp(`${escapeStringForRegExp(cookieName)}=[^;]+(?:;|$)`);
142
+ export async function extractCookieJwt({ rawCookie, jwtParams, cookieName, cookieNameSuffix, }) {
143
+ const resolvedName = resolveCookieName(cookieName, cookieNameSuffix);
144
+ const cookieRegExp = new RegExp(`${escapeStringForRegExp(resolvedName)}=[^;]+(?:;|$)`);
130
145
  const [cookieValue] = safeMatch(rawCookie, cookieRegExp);
131
146
  if (!cookieValue) {
132
147
  return undefined;
133
148
  }
134
- const rawJwt = cookieValue.replace(`${cookieName}=`, '').replace(';', '');
149
+ const rawJwt = cookieValue.replace(`${resolvedName}=`, '').replace(';', '');
135
150
  const jwt = await parseUserJwt(rawJwt, jwtParams);
136
151
  return jwt;
137
152
  }
@@ -30,4 +30,4 @@ export declare function resolveCsrfHeaderName(options: Readonly<CsrfHeaderNameOp
30
30
  *
31
31
  * @category Auth : Client
32
32
  */
33
- export declare function getCurrentCsrfToken(): string | undefined;
33
+ export declare function getCurrentCsrfToken(cookieNameSuffix?: string | undefined): string | undefined;
@@ -1,6 +1,6 @@
1
1
  import { check } from '@augment-vir/assert';
2
2
  import { escapeStringForRegExp, randomString, safeMatch } from '@augment-vir/common';
3
- import { AuthCookie } from './cookie.js';
3
+ import { AuthCookie, resolveCookieName } from './cookie.js';
4
4
  /**
5
5
  * Generates a random, cryptographically secure CSRF token string.
6
6
  *
@@ -35,8 +35,9 @@ export function resolveCsrfHeaderName(options) {
35
35
  *
36
36
  * @category Auth : Client
37
37
  */
38
- export function getCurrentCsrfToken() {
39
- const cookieRegExp = new RegExp(`${escapeStringForRegExp(AuthCookie.Csrf)}=([^;]+)`);
38
+ export function getCurrentCsrfToken(cookieNameSuffix) {
39
+ const resolvedName = resolveCookieName(AuthCookie.Csrf, cookieNameSuffix);
40
+ const cookieRegExp = new RegExp(`${escapeStringForRegExp(resolvedName)}=([^;]+)`);
40
41
  const [, value,] = safeMatch(globalThis.document.cookie, cookieRegExp);
41
42
  return value || undefined;
42
43
  }
@@ -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.4",
3
+ "version": "5.2.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,18 @@ 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
+ resolveCookieName,
29
+ type CookieParams,
30
+ } from '../cookie.js';
31
+ import {generateCsrfToken, type CsrfHeaderNameOption} from '../csrf-token.js';
26
32
  import {AuthHeaderName, mergeHeaderValues} from '../headers.js';
27
33
  import {generateNewJwtKeys, parseJwtKeys, type JwtKeys, type RawJwtKeys} from '../jwt/jwt-keys.js';
28
34
  import {defaultAllowedClockSkew, type CreateJwtParams, type ParseJwtParams} from '../jwt/jwt.js';
@@ -168,6 +174,25 @@ export type BackendAuthClientConfig<
168
174
  * @default {minutes: 5}
169
175
  */
170
176
  allowedClockSkew: Readonly<AnyDuration>;
177
+ /**
178
+ * Optional separate origin for the CSRF cookie's `Domain` attribute. When set, the
179
+ * non-`HttpOnly` CSRF cookie will use this origin's hostname instead of `serviceOrigin`.
180
+ *
181
+ * This is useful when the backend and frontend live on different subdomains that don't
182
+ * share a common parent narrower than the top-level domain. The `HttpOnly` auth cookie
183
+ * stays scoped to `serviceOrigin` (protecting it from unrelated subdomains), while the CSRF
184
+ * cookie uses the broader domain so frontend JavaScript can read it via `document.cookie`.
185
+ *
186
+ * The CSRF token alone is not a security risk — it is only meaningful when paired with the
187
+ * JWT embedded in the `HttpOnly` auth cookie.
188
+ */
189
+ csrfCookieOrigin: string;
190
+ /**
191
+ * Optional suffix appended to cookie names (e.g., `'staging'` produces `auth-staging`,
192
+ * `auth-vir-csrf-staging`). When `undefined`, cookie names are unchanged. Useful for
193
+ * running multiple environments on the same domain without cookie collisions.
194
+ */
195
+ cookieNameSuffix: string;
171
196
  }>
172
197
  >;
173
198
 
@@ -201,6 +226,14 @@ export class BackendAuthClient<
201
226
  protected readonly config: BackendAuthClientConfig<DatabaseUser, UserId, AssumedUserParams>,
202
227
  ) {}
203
228
 
229
+ /**
230
+ * Resolves the origin to use for CSRF cookie generation. Returns `csrfCookieOrigin` if
231
+ * configured, otherwise falls back to the auth cookie origin.
232
+ */
233
+ protected resolveCsrfCookieOrigin(authCookieOrigin: string): string {
234
+ return this.config.csrfCookieOrigin || authCookieOrigin;
235
+ }
236
+
204
237
  /** Conditionally logs a message if logging is enabled for the given user context. */
205
238
  protected logForUser(
206
239
  params: {
@@ -251,6 +284,7 @@ export class BackendAuthClient<
251
284
  jwtParams: await this.getJwtParams(),
252
285
  isDev: this.config.isDev,
253
286
  authCookie: isSignUpCookie ? AuthCookie.SignUp : AuthCookie.Auth,
287
+ cookieNameSuffix: this.config.cookieNameSuffix,
254
288
  };
255
289
  }
256
290
 
@@ -383,7 +417,11 @@ export class BackendAuthClient<
383
417
  },
384
418
  cookieParams,
385
419
  );
386
- const csrfCookie = generateCsrfCookie(userIdResult.csrfToken, cookieParams);
420
+ const csrfCookie = generateCsrfCookie(userIdResult.csrfToken, {
421
+ ...cookieParams,
422
+ hostOrigin: this.resolveCsrfCookieOrigin(cookieParams.hostOrigin),
423
+ cookieNameSuffix: this.config.cookieNameSuffix,
424
+ });
387
425
 
388
426
  return {
389
427
  'set-cookie': [
@@ -460,12 +498,13 @@ export class BackendAuthClient<
460
498
  */
461
499
  allowUserAuthRefresh: boolean;
462
500
  }): Promise<GetUserResult<DatabaseUser> | undefined> {
463
- const userIdResult = await extractUserIdFromRequestHeaders<UserId>(
464
- requestHeaders,
465
- await this.getJwtParams(),
466
- this.config.csrf,
467
- isSignUpCookie ? AuthCookie.SignUp : AuthCookie.Auth,
468
- );
501
+ const userIdResult = await extractUserIdFromRequestHeaders<UserId>({
502
+ headers: requestHeaders,
503
+ jwtParams: await this.getJwtParams(),
504
+ csrfHeaderNameOption: this.config.csrf,
505
+ cookieName: isSignUpCookie ? AuthCookie.SignUp : AuthCookie.Auth,
506
+ cookieNameSuffix: this.config.cookieNameSuffix,
507
+ });
469
508
  if (!userIdResult) {
470
509
  this.logForUser(
471
510
  {
@@ -519,12 +558,15 @@ export class BackendAuthClient<
519
558
  * Always include the CSRF cookie so it gets re-established if the browser clears it. When
520
559
  * session refresh fires, its headers already include a CSRF cookie.
521
560
  */
561
+ const authCookieOrigin =
562
+ (await this.config.generateServiceOrigin?.({
563
+ requestHeaders,
564
+ })) || this.config.serviceOrigin;
565
+
522
566
  const csrfCookie = generateCsrfCookie(userIdResult.csrfToken, {
523
- hostOrigin:
524
- (await this.config.generateServiceOrigin?.({
525
- requestHeaders,
526
- })) || this.config.serviceOrigin,
567
+ hostOrigin: this.resolveCsrfCookieOrigin(authCookieOrigin),
527
568
  isDev: this.config.isDev,
569
+ cookieNameSuffix: this.config.cookieNameSuffix,
528
570
  });
529
571
 
530
572
  return {
@@ -605,10 +647,24 @@ export class BackendAuthClient<
605
647
  )
606
648
  : undefined;
607
649
 
650
+ /**
651
+ * When `csrfCookieOrigin` is configured, the CSRF cookie lives on a broader domain than the
652
+ * auth cookie. Clear it on that broader domain too so stale tokens don't linger.
653
+ */
654
+ const broadCsrfClearCookie =
655
+ clearingAllCookies && this.config.csrfCookieOrigin
656
+ ? clearCsrfCookie({
657
+ hostOrigin: this.config.csrfCookieOrigin,
658
+ isDev: this.config.isDev,
659
+ cookieNameSuffix: this.config.cookieNameSuffix,
660
+ })
661
+ : undefined;
662
+
608
663
  return {
609
664
  'set-cookie': mergeHeaderValues(
610
665
  signUpCookieHeaders?.['set-cookie'],
611
666
  authCookieHeaders?.['set-cookie'],
667
+ broadCsrfClearCookie,
612
668
  ),
613
669
  };
614
670
  }
@@ -635,7 +691,41 @@ export class BackendAuthClient<
635
691
  cookieParams,
636
692
  );
637
693
 
638
- const csrfCookie = generateCsrfCookie(existingUserIdResult.csrfToken, cookieParams);
694
+ const csrfCookie = generateCsrfCookie(existingUserIdResult.csrfToken, {
695
+ ...cookieParams,
696
+ hostOrigin: this.resolveCsrfCookieOrigin(cookieParams.hostOrigin),
697
+ cookieNameSuffix: this.config.cookieNameSuffix,
698
+ });
699
+
700
+ return {
701
+ 'set-cookie': [
702
+ authCookie,
703
+ csrfCookie,
704
+ ],
705
+ };
706
+ }
707
+
708
+ /** Generates login headers for a brand-new session (no existing JWT to reuse). */
709
+ protected async generateFreshLoginHeaders(
710
+ userId: UserId,
711
+ cookieParams: Readonly<CookieParams>,
712
+ ): Promise<Record<string, string[]>> {
713
+ const csrfToken = generateCsrfToken();
714
+
715
+ const authCookie = await generateAuthCookie(
716
+ {
717
+ csrfToken,
718
+ userId,
719
+ sessionStartedAt: Date.now(),
720
+ },
721
+ cookieParams,
722
+ );
723
+
724
+ const csrfCookie = generateCsrfCookie(csrfToken, {
725
+ ...cookieParams,
726
+ hostOrigin: this.resolveCsrfCookieOrigin(cookieParams.hostOrigin),
727
+ cookieNameSuffix: this.config.cookieNameSuffix,
728
+ });
639
729
 
640
730
  return {
641
731
  'set-cookie': [
@@ -656,7 +746,13 @@ export class BackendAuthClient<
656
746
  isSignUpCookie: boolean;
657
747
  }): Promise<OutgoingHttpHeaders> {
658
748
  const oppositeCookieName = isSignUpCookie ? AuthCookie.Auth : AuthCookie.SignUp;
659
- const hasExistingOppositeCookie = requestHeaders.cookie?.includes(`${oppositeCookieName}=`);
749
+ const resolvedOppositeCookieName = resolveCookieName(
750
+ oppositeCookieName,
751
+ this.config.cookieNameSuffix,
752
+ );
753
+ const hasExistingOppositeCookie = requestHeaders.cookie?.includes(
754
+ `${resolvedOppositeCookieName}=`,
755
+ );
660
756
 
661
757
  const discardOppositeCookieHeaders = hasExistingOppositeCookie
662
758
  ? generateLogoutHeaders(
@@ -670,12 +766,13 @@ export class BackendAuthClient<
670
766
  )
671
767
  : undefined;
672
768
 
673
- const existingUserIdResult = await extractUserIdFromRequestHeaders<UserId>(
674
- requestHeaders,
675
- await this.getJwtParams(),
676
- this.config.csrf,
677
- isSignUpCookie ? AuthCookie.SignUp : AuthCookie.Auth,
678
- );
769
+ const existingUserIdResult = await extractUserIdFromRequestHeaders<UserId>({
770
+ headers: requestHeaders,
771
+ jwtParams: await this.getJwtParams(),
772
+ csrfHeaderNameOption: this.config.csrf,
773
+ cookieName: isSignUpCookie ? AuthCookie.SignUp : AuthCookie.Auth,
774
+ cookieNameSuffix: this.config.cookieNameSuffix,
775
+ });
679
776
 
680
777
  const cookieParams = await this.getCookieParams({
681
778
  isSignUpCookie,
@@ -688,7 +785,7 @@ export class BackendAuthClient<
688
785
  cookieParams,
689
786
  existingUserIdResult,
690
787
  })
691
- : await generateSuccessfulLoginHeaders(userId, cookieParams);
788
+ : await this.generateFreshLoginHeaders(userId, cookieParams);
692
789
 
693
790
  return {
694
791
  ...newCookieHeaders,
@@ -764,11 +861,12 @@ export class BackendAuthClient<
764
861
  allowUserAuthRefresh: boolean;
765
862
  }): Promise<GetUserResult<DatabaseUser> | undefined> {
766
863
  // eslint-disable-next-line @typescript-eslint/no-deprecated
767
- const userIdResult = await insecureExtractUserIdFromCookieAlone<UserId>(
768
- requestHeaders,
769
- await this.getJwtParams(),
770
- AuthCookie.Auth,
771
- );
864
+ const userIdResult = await insecureExtractUserIdFromCookieAlone<UserId>({
865
+ headers: requestHeaders,
866
+ jwtParams: await this.getJwtParams(),
867
+ cookieName: AuthCookie.Auth,
868
+ cookieNameSuffix: this.config.cookieNameSuffix,
869
+ });
772
870
 
773
871
  if (!userIdResult) {
774
872
  this.logForUser(
@@ -25,6 +25,11 @@ export type FrontendAuthClientConfig = Readonly<{
25
25
  csrf: Readonly<CsrfHeaderNameOption>;
26
26
  }> &
27
27
  PartialWithUndefined<{
28
+ /**
29
+ * Optional suffix appended to cookie names (e.g., `'staging'` produces
30
+ * `auth-vir-csrf-staging`). When `undefined`, cookie names are unchanged.
31
+ */
32
+ cookieNameSuffix: string;
28
33
  /**
29
34
  * Determine if the current user can assume the identity of another user. If this is not
30
35
  * defined, all users will be blocked from assuming other user identities.
@@ -163,7 +168,7 @@ export class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject =
163
168
  * combine them with these.
164
169
  */
165
170
  public createAuthenticatedRequestInit(): RequestInit {
166
- const csrfToken = getCurrentCsrfToken();
171
+ const csrfToken = getCurrentCsrfToken(this.config.cookieNameSuffix);
167
172
 
168
173
  const assumedUser = this.getAssumedUser();
169
174
  const headers: HeadersInit = {
package/src/auth.ts CHANGED
@@ -1,7 +1,7 @@
1
- import {type SelectFrom} from '@augment-vir/common';
1
+ import {type PartialWithUndefined, type SelectFrom} from '@augment-vir/common';
2
2
  import {type FullDate, type UtcTimezone} from 'date-vir';
3
3
  import {
4
- AuthCookie,
4
+ type AuthCookie,
5
5
  clearAuthCookie,
6
6
  clearCsrfCookie,
7
7
  type CookieParams,
@@ -71,12 +71,19 @@ function readCsrfTokenHeader(
71
71
  * @category Auth : Host
72
72
  * @returns The extracted user id or `undefined` if no valid auth headers exist.
73
73
  */
74
- export async function extractUserIdFromRequestHeaders<UserId extends string | number>(
75
- headers: HeaderContainer,
76
- jwtParams: Readonly<ParseJwtParams>,
77
- csrfHeaderNameOption: Readonly<CsrfHeaderNameOption>,
78
- cookieName: AuthCookie = AuthCookie.Auth,
79
- ): Promise<Readonly<UserIdResult<UserId>> | undefined> {
74
+ export async function extractUserIdFromRequestHeaders<UserId extends string | number>({
75
+ headers,
76
+ jwtParams,
77
+ csrfHeaderNameOption,
78
+ cookieName,
79
+ cookieNameSuffix,
80
+ }: Readonly<{
81
+ headers: HeaderContainer;
82
+ jwtParams: Readonly<ParseJwtParams>;
83
+ csrfHeaderNameOption: Readonly<CsrfHeaderNameOption>;
84
+ cookieName: AuthCookie;
85
+ cookieNameSuffix?: string | undefined;
86
+ }>): Promise<Readonly<UserIdResult<UserId>> | undefined> {
80
87
  try {
81
88
  const csrfToken = readCsrfTokenHeader(headers, csrfHeaderNameOption);
82
89
  const cookie = readHeader(headers, 'cookie');
@@ -85,7 +92,12 @@ export async function extractUserIdFromRequestHeaders<UserId extends string | nu
85
92
  return undefined;
86
93
  }
87
94
 
88
- const jwt = await extractCookieJwt(cookie, jwtParams, cookieName);
95
+ const jwt = await extractCookieJwt({
96
+ rawCookie: cookie,
97
+ jwtParams,
98
+ cookieName,
99
+ cookieNameSuffix,
100
+ });
89
101
 
90
102
  if (!jwt || jwt.data.csrfToken !== csrfToken) {
91
103
  return undefined;
@@ -112,11 +124,17 @@ export async function extractUserIdFromRequestHeaders<UserId extends string | nu
112
124
  * @deprecated Prefer {@link extractUserIdFromRequestHeaders} instead: it is more secure.
113
125
  * @category Auth : Host
114
126
  */
115
- export async function insecureExtractUserIdFromCookieAlone<UserId extends string | number>(
116
- headers: HeaderContainer,
117
- jwtParams: Readonly<ParseJwtParams>,
118
- cookieName: AuthCookie,
119
- ): Promise<Readonly<UserIdResult<UserId>> | undefined> {
127
+ export async function insecureExtractUserIdFromCookieAlone<UserId extends string | number>({
128
+ headers,
129
+ jwtParams,
130
+ cookieName,
131
+ cookieNameSuffix,
132
+ }: Readonly<{
133
+ headers: HeaderContainer;
134
+ jwtParams: Readonly<ParseJwtParams>;
135
+ cookieName: AuthCookie;
136
+ cookieNameSuffix?: string | undefined;
137
+ }>): Promise<Readonly<UserIdResult<UserId>> | undefined> {
120
138
  try {
121
139
  const cookie = readHeader(headers, 'cookie');
122
140
 
@@ -124,7 +142,12 @@ export async function insecureExtractUserIdFromCookieAlone<UserId extends string
124
142
  return undefined;
125
143
  }
126
144
 
127
- const jwt = await extractCookieJwt(cookie, jwtParams, cookieName);
145
+ const jwt = await extractCookieJwt({
146
+ rawCookie: cookie,
147
+ jwtParams,
148
+ cookieName,
149
+ cookieNameSuffix,
150
+ });
128
151
 
129
152
  if (!jwt) {
130
153
  return undefined;
@@ -188,15 +211,18 @@ export async function generateSuccessfulLoginHeaders(
188
211
  * @category Auth : Host
189
212
  */
190
213
  export function generateLogoutHeaders(
191
- cookieConfig: Readonly<SelectFrom<CookieParams, {hostOrigin: true; isDev: true}>>,
192
- options?: Readonly<{
193
- /**
194
- * When `true`, the CSRF cookie is preserved (not cleared). Use this when clearing only one
195
- * cookie type (e.g., the auth cookie) while keeping the other active session (e.g.,
196
- * sign-up) that still needs its CSRF token.
197
- */
198
- preserveCsrf?: boolean | undefined;
199
- }>,
214
+ cookieConfig: Readonly<SelectFrom<CookieParams, {hostOrigin: true; isDev: true}>> &
215
+ PartialWithUndefined<{cookieNameSuffix: string}>,
216
+ options?: Readonly<
217
+ PartialWithUndefined<{
218
+ /**
219
+ * When `true`, the CSRF cookie is preserved (not cleared). Use this when clearing only
220
+ * one cookie type (e.g., the auth cookie) while keeping the other active session (e.g.,
221
+ * sign-up) that still needs its CSRF token.
222
+ */
223
+ preserveCsrf: boolean;
224
+ }>
225
+ >,
200
226
  ): Record<string, string[]> {
201
227
  return {
202
228
  'set-cookie': [
package/src/cookie.ts CHANGED
@@ -25,6 +25,24 @@ export enum AuthCookie {
25
25
  Csrf = 'auth-vir-csrf',
26
26
  }
27
27
 
28
+ /**
29
+ * Resolves a cookie name by appending a suffix when provided. When `cookieNameSuffix` is
30
+ * `undefined`, the base name is returned unchanged.
31
+ *
32
+ * @category Internal
33
+ */
34
+ export function resolveCookieName(
35
+ baseCookieName: AuthCookie,
36
+ cookieNameSuffix?: string | undefined,
37
+ ): string {
38
+ return [
39
+ baseCookieName,
40
+ cookieNameSuffix,
41
+ ]
42
+ .filter(check.isTruthy)
43
+ .join('-');
44
+ }
45
+
28
46
  /**
29
47
  * Parameters for {@link generateAuthCookie}.
30
48
  *
@@ -63,6 +81,12 @@ export type CookieParams = {
63
81
  * @default false
64
82
  */
65
83
  isDev: boolean;
84
+ /**
85
+ * Optional suffix appended to cookie names (e.g., `'staging'` produces `auth-staging`). When
86
+ * `undefined`, cookie names are unchanged. Useful for running multiple environments on the same
87
+ * domain without cookie collisions.
88
+ */
89
+ cookieNameSuffix: string;
66
90
  }>;
67
91
 
68
92
  function generateSetCookie({
@@ -102,7 +126,10 @@ export async function generateAuthCookie(
102
126
  cookieConfig: Readonly<CookieParams>,
103
127
  ): Promise<string> {
104
128
  return generateSetCookie({
105
- name: cookieConfig.authCookie || AuthCookie.Auth,
129
+ name: resolveCookieName(
130
+ cookieConfig.authCookie || AuthCookie.Auth,
131
+ cookieConfig.cookieNameSuffix,
132
+ ),
106
133
  value: await createUserJwt(userJwtData, cookieConfig.jwtParams),
107
134
  httpOnly: true,
108
135
  cookieConfig,
@@ -122,10 +149,11 @@ export async function generateAuthCookie(
122
149
  */
123
150
  export function generateCsrfCookie(
124
151
  csrfToken: string,
125
- cookieConfig: Readonly<SelectFrom<CookieParams, {hostOrigin: true; isDev: true}>>,
152
+ cookieConfig: Readonly<SelectFrom<CookieParams, {hostOrigin: true; isDev: true}>> &
153
+ PartialWithUndefined<{cookieNameSuffix: string}>,
126
154
  ): string {
127
155
  return generateSetCookie({
128
- name: AuthCookie.Csrf,
156
+ name: resolveCookieName(AuthCookie.Csrf, cookieConfig.cookieNameSuffix),
129
157
  value: csrfToken,
130
158
  httpOnly: false,
131
159
  cookieConfig: {
@@ -144,10 +172,13 @@ export function generateCsrfCookie(
144
172
  */
145
173
  export function clearAuthCookie(
146
174
  cookieConfig: Readonly<SelectFrom<CookieParams, {hostOrigin: true; isDev: true}>> &
147
- PartialWithUndefined<{authCookie: AuthCookie}>,
175
+ PartialWithUndefined<{authCookie: AuthCookie; cookieNameSuffix: string}>,
148
176
  ) {
149
177
  return generateSetCookie({
150
- name: cookieConfig.authCookie || AuthCookie.Auth,
178
+ name: resolveCookieName(
179
+ cookieConfig.authCookie || AuthCookie.Auth,
180
+ cookieConfig.cookieNameSuffix,
181
+ ),
151
182
  value: 'redacted',
152
183
  httpOnly: true,
153
184
  cookieConfig,
@@ -160,10 +191,11 @@ export function clearAuthCookie(
160
191
  * @category Internal
161
192
  */
162
193
  export function clearCsrfCookie(
163
- cookieConfig: Readonly<SelectFrom<CookieParams, {hostOrigin: true; isDev: true}>>,
194
+ cookieConfig: Readonly<SelectFrom<CookieParams, {hostOrigin: true; isDev: true}>> &
195
+ PartialWithUndefined<{cookieNameSuffix: string}>,
164
196
  ) {
165
197
  return generateSetCookie({
166
- name: AuthCookie.Csrf,
198
+ name: resolveCookieName(AuthCookie.Csrf, cookieConfig.cookieNameSuffix),
167
199
  value: 'redacted',
168
200
  httpOnly: false,
169
201
  cookieConfig,
@@ -206,12 +238,20 @@ export function generateCookie(
206
238
  * @category Internal
207
239
  * @returns The extracted auth Cookie JWT data or `undefined` if no valid auth JWT data was found.
208
240
  */
209
- export async function extractCookieJwt(
210
- rawCookie: string,
211
- jwtParams: Readonly<ParseJwtParams>,
212
- cookieName: AuthCookie,
213
- ): Promise<undefined | ParsedJwt<JwtUserData>> {
214
- const cookieRegExp = new RegExp(`${escapeStringForRegExp(cookieName)}=[^;]+(?:;|$)`);
241
+ export async function extractCookieJwt({
242
+ rawCookie,
243
+ jwtParams,
244
+ cookieName,
245
+ cookieNameSuffix,
246
+ }: {
247
+ rawCookie: string;
248
+ jwtParams: Readonly<ParseJwtParams>;
249
+ cookieName: AuthCookie;
250
+ } & PartialWithUndefined<{
251
+ cookieNameSuffix: string;
252
+ }>): Promise<undefined | ParsedJwt<JwtUserData>> {
253
+ const resolvedName = resolveCookieName(cookieName, cookieNameSuffix);
254
+ const cookieRegExp = new RegExp(`${escapeStringForRegExp(resolvedName)}=[^;]+(?:;|$)`);
215
255
 
216
256
  const [cookieValue] = safeMatch(rawCookie, cookieRegExp);
217
257
 
@@ -219,7 +259,7 @@ export async function extractCookieJwt(
219
259
  return undefined;
220
260
  }
221
261
 
222
- const rawJwt = cookieValue.replace(`${cookieName}=`, '').replace(';', '');
262
+ const rawJwt = cookieValue.replace(`${resolvedName}=`, '').replace(';', '');
223
263
 
224
264
  const jwt = await parseUserJwt(rawJwt, jwtParams);
225
265
 
package/src/csrf-token.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import {check} from '@augment-vir/assert';
2
2
  import {escapeStringForRegExp, randomString, safeMatch} from '@augment-vir/common';
3
3
  import {type RequireExactlyOne} from 'type-fest';
4
- import {AuthCookie} from './cookie.js';
4
+ import {AuthCookie, resolveCookieName} from './cookie.js';
5
5
 
6
6
  /**
7
7
  * Generates a random, cryptographically secure CSRF token string.
@@ -51,8 +51,9 @@ export function resolveCsrfHeaderName(options: Readonly<CsrfHeaderNameOption>):
51
51
  *
52
52
  * @category Auth : Client
53
53
  */
54
- export function getCurrentCsrfToken(): string | undefined {
55
- const cookieRegExp = new RegExp(`${escapeStringForRegExp(AuthCookie.Csrf)}=([^;]+)`);
54
+ export function getCurrentCsrfToken(cookieNameSuffix?: string | undefined): string | undefined {
55
+ const resolvedName = resolveCookieName(AuthCookie.Csrf, cookieNameSuffix);
56
+ const cookieRegExp = new RegExp(`${escapeStringForRegExp(resolvedName)}=([^;]+)`);
56
57
  const [
57
58
  ,
58
59
  value,
@@ -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
  {