auth-vir 5.1.0 → 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
 
@@ -150,6 +150,12 @@ export type BackendAuthClientConfig<DatabaseUser extends AnyObject, UserId exten
150
150
  * JWT embedded in the `HttpOnly` auth cookie.
151
151
  */
152
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;
153
159
  }>>;
154
160
  /**
155
161
  * An auth client for creating and validating JWTs embedded in cookies. This should only be used in
@@ -1,7 +1,7 @@
1
1
  import { ensureArray, } from '@augment-vir/common';
2
2
  import { calculateRelativeDate, createUtcFullDate, getNowInUtcTimezone, isDateAfter, } from 'date-vir';
3
3
  import { extractUserIdFromRequestHeaders, generateLogoutHeaders, insecureExtractUserIdFromCookieAlone, } from '../auth.js';
4
- import { AuthCookie, clearCsrfCookie, generateAuthCookie, generateCsrfCookie, } from '../cookie.js';
4
+ import { AuthCookie, clearCsrfCookie, generateAuthCookie, generateCsrfCookie, resolveCookieName, } from '../cookie.js';
5
5
  import { generateCsrfToken } from '../csrf-token.js';
6
6
  import { AuthHeaderName, mergeHeaderValues } from '../headers.js';
7
7
  import { parseJwtKeys } from '../jwt/jwt-keys.js';
@@ -64,6 +64,7 @@ export class BackendAuthClient {
64
64
  jwtParams: await this.getJwtParams(),
65
65
  isDev: this.config.isDev,
66
66
  authCookie: isSignUpCookie ? AuthCookie.SignUp : AuthCookie.Auth,
67
+ cookieNameSuffix: this.config.cookieNameSuffix,
67
68
  };
68
69
  }
69
70
  /** Calls the provided `getUserFromDatabase` config. */
@@ -154,6 +155,7 @@ export class BackendAuthClient {
154
155
  const csrfCookie = generateCsrfCookie(userIdResult.csrfToken, {
155
156
  ...cookieParams,
156
157
  hostOrigin: this.resolveCsrfCookieOrigin(cookieParams.hostOrigin),
158
+ cookieNameSuffix: this.config.cookieNameSuffix,
157
159
  });
158
160
  return {
159
161
  'set-cookie': [
@@ -197,7 +199,13 @@ export class BackendAuthClient {
197
199
  }
198
200
  /** Securely extract a user from their request headers. */
199
201
  async getSecureUser({ requestHeaders, isSignUpCookie, allowUserAuthRefresh, }) {
200
- 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
+ });
201
209
  if (!userIdResult) {
202
210
  this.logForUser({
203
211
  user: undefined,
@@ -244,6 +252,7 @@ export class BackendAuthClient {
244
252
  const csrfCookie = generateCsrfCookie(userIdResult.csrfToken, {
245
253
  hostOrigin: this.resolveCsrfCookieOrigin(authCookieOrigin),
246
254
  isDev: this.config.isDev,
255
+ cookieNameSuffix: this.config.cookieNameSuffix,
247
256
  });
248
257
  return {
249
258
  user: assumedUser || user,
@@ -302,6 +311,7 @@ export class BackendAuthClient {
302
311
  ? clearCsrfCookie({
303
312
  hostOrigin: this.config.csrfCookieOrigin,
304
313
  isDev: this.config.isDev,
314
+ cookieNameSuffix: this.config.cookieNameSuffix,
305
315
  })
306
316
  : undefined;
307
317
  return {
@@ -321,6 +331,7 @@ export class BackendAuthClient {
321
331
  const csrfCookie = generateCsrfCookie(existingUserIdResult.csrfToken, {
322
332
  ...cookieParams,
323
333
  hostOrigin: this.resolveCsrfCookieOrigin(cookieParams.hostOrigin),
334
+ cookieNameSuffix: this.config.cookieNameSuffix,
324
335
  });
325
336
  return {
326
337
  'set-cookie': [
@@ -340,6 +351,7 @@ export class BackendAuthClient {
340
351
  const csrfCookie = generateCsrfCookie(csrfToken, {
341
352
  ...cookieParams,
342
353
  hostOrigin: this.resolveCsrfCookieOrigin(cookieParams.hostOrigin),
354
+ cookieNameSuffix: this.config.cookieNameSuffix,
343
355
  });
344
356
  return {
345
357
  'set-cookie': [
@@ -351,7 +363,8 @@ export class BackendAuthClient {
351
363
  /** Use these headers to log a user in. */
352
364
  async createLoginHeaders({ userId, requestHeaders, isSignUpCookie, }) {
353
365
  const oppositeCookieName = isSignUpCookie ? AuthCookie.Auth : AuthCookie.SignUp;
354
- const hasExistingOppositeCookie = requestHeaders.cookie?.includes(`${oppositeCookieName}=`);
366
+ const resolvedOppositeCookieName = resolveCookieName(oppositeCookieName, this.config.cookieNameSuffix);
367
+ const hasExistingOppositeCookie = requestHeaders.cookie?.includes(`${resolvedOppositeCookieName}=`);
355
368
  const discardOppositeCookieHeaders = hasExistingOppositeCookie
356
369
  ? generateLogoutHeaders(await this.getCookieParams({
357
370
  isSignUpCookie: !isSignUpCookie,
@@ -360,7 +373,13 @@ export class BackendAuthClient {
360
373
  preserveCsrf: true,
361
374
  })
362
375
  : undefined;
363
- 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
+ });
364
383
  const cookieParams = await this.getCookieParams({
365
384
  isSignUpCookie,
366
385
  requestHeaders,
@@ -405,7 +424,12 @@ export class BackendAuthClient {
405
424
  */
406
425
  async getInsecureUser({ requestHeaders, allowUserAuthRefresh, }) {
407
426
  // eslint-disable-next-line @typescript-eslint/no-deprecated
408
- 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
+ });
409
433
  if (!userIdResult) {
410
434
  this.logForUser({
411
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auth-vir",
3
- "version": "5.1.0",
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",
@@ -25,6 +25,7 @@ import {
25
25
  clearCsrfCookie,
26
26
  generateAuthCookie,
27
27
  generateCsrfCookie,
28
+ resolveCookieName,
28
29
  type CookieParams,
29
30
  } from '../cookie.js';
30
31
  import {generateCsrfToken, type CsrfHeaderNameOption} from '../csrf-token.js';
@@ -186,6 +187,12 @@ export type BackendAuthClientConfig<
186
187
  * JWT embedded in the `HttpOnly` auth cookie.
187
188
  */
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;
189
196
  }>
190
197
  >;
191
198
 
@@ -277,6 +284,7 @@ export class BackendAuthClient<
277
284
  jwtParams: await this.getJwtParams(),
278
285
  isDev: this.config.isDev,
279
286
  authCookie: isSignUpCookie ? AuthCookie.SignUp : AuthCookie.Auth,
287
+ cookieNameSuffix: this.config.cookieNameSuffix,
280
288
  };
281
289
  }
282
290
 
@@ -412,6 +420,7 @@ export class BackendAuthClient<
412
420
  const csrfCookie = generateCsrfCookie(userIdResult.csrfToken, {
413
421
  ...cookieParams,
414
422
  hostOrigin: this.resolveCsrfCookieOrigin(cookieParams.hostOrigin),
423
+ cookieNameSuffix: this.config.cookieNameSuffix,
415
424
  });
416
425
 
417
426
  return {
@@ -489,12 +498,13 @@ export class BackendAuthClient<
489
498
  */
490
499
  allowUserAuthRefresh: boolean;
491
500
  }): Promise<GetUserResult<DatabaseUser> | undefined> {
492
- const userIdResult = await extractUserIdFromRequestHeaders<UserId>(
493
- requestHeaders,
494
- await this.getJwtParams(),
495
- this.config.csrf,
496
- isSignUpCookie ? AuthCookie.SignUp : AuthCookie.Auth,
497
- );
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
+ });
498
508
  if (!userIdResult) {
499
509
  this.logForUser(
500
510
  {
@@ -556,6 +566,7 @@ export class BackendAuthClient<
556
566
  const csrfCookie = generateCsrfCookie(userIdResult.csrfToken, {
557
567
  hostOrigin: this.resolveCsrfCookieOrigin(authCookieOrigin),
558
568
  isDev: this.config.isDev,
569
+ cookieNameSuffix: this.config.cookieNameSuffix,
559
570
  });
560
571
 
561
572
  return {
@@ -645,6 +656,7 @@ export class BackendAuthClient<
645
656
  ? clearCsrfCookie({
646
657
  hostOrigin: this.config.csrfCookieOrigin,
647
658
  isDev: this.config.isDev,
659
+ cookieNameSuffix: this.config.cookieNameSuffix,
648
660
  })
649
661
  : undefined;
650
662
 
@@ -682,6 +694,7 @@ export class BackendAuthClient<
682
694
  const csrfCookie = generateCsrfCookie(existingUserIdResult.csrfToken, {
683
695
  ...cookieParams,
684
696
  hostOrigin: this.resolveCsrfCookieOrigin(cookieParams.hostOrigin),
697
+ cookieNameSuffix: this.config.cookieNameSuffix,
685
698
  });
686
699
 
687
700
  return {
@@ -711,6 +724,7 @@ export class BackendAuthClient<
711
724
  const csrfCookie = generateCsrfCookie(csrfToken, {
712
725
  ...cookieParams,
713
726
  hostOrigin: this.resolveCsrfCookieOrigin(cookieParams.hostOrigin),
727
+ cookieNameSuffix: this.config.cookieNameSuffix,
714
728
  });
715
729
 
716
730
  return {
@@ -732,7 +746,13 @@ export class BackendAuthClient<
732
746
  isSignUpCookie: boolean;
733
747
  }): Promise<OutgoingHttpHeaders> {
734
748
  const oppositeCookieName = isSignUpCookie ? AuthCookie.Auth : AuthCookie.SignUp;
735
- 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
+ );
736
756
 
737
757
  const discardOppositeCookieHeaders = hasExistingOppositeCookie
738
758
  ? generateLogoutHeaders(
@@ -746,12 +766,13 @@ export class BackendAuthClient<
746
766
  )
747
767
  : undefined;
748
768
 
749
- const existingUserIdResult = await extractUserIdFromRequestHeaders<UserId>(
750
- requestHeaders,
751
- await this.getJwtParams(),
752
- this.config.csrf,
753
- isSignUpCookie ? AuthCookie.SignUp : AuthCookie.Auth,
754
- );
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
+ });
755
776
 
756
777
  const cookieParams = await this.getCookieParams({
757
778
  isSignUpCookie,
@@ -840,11 +861,12 @@ export class BackendAuthClient<
840
861
  allowUserAuthRefresh: boolean;
841
862
  }): Promise<GetUserResult<DatabaseUser> | undefined> {
842
863
  // eslint-disable-next-line @typescript-eslint/no-deprecated
843
- const userIdResult = await insecureExtractUserIdFromCookieAlone<UserId>(
844
- requestHeaders,
845
- await this.getJwtParams(),
846
- AuthCookie.Auth,
847
- );
864
+ const userIdResult = await insecureExtractUserIdFromCookieAlone<UserId>({
865
+ headers: requestHeaders,
866
+ jwtParams: await this.getJwtParams(),
867
+ cookieName: AuthCookie.Auth,
868
+ cookieNameSuffix: this.config.cookieNameSuffix,
869
+ });
848
870
 
849
871
  if (!userIdResult) {
850
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,