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 +5 -5
- package/dist/auth-client/backend-auth.client.d.ts +26 -0
- package/dist/auth-client/backend-auth.client.js +81 -13
- package/dist/auth-client/frontend-auth.client.d.ts +5 -0
- package/dist/auth-client/frontend-auth.client.js +1 -1
- package/dist/auth.d.ts +22 -9
- package/dist/auth.js +15 -5
- package/dist/cookie.d.ts +27 -3
- package/dist/cookie.js +22 -7
- package/dist/csrf-token.d.ts +1 -1
- package/dist/csrf-token.js +4 -3
- package/dist/generated/internal/class.js +2 -2
- package/package.json +1 -1
- package/src/auth-client/backend-auth.client.ts +126 -28
- package/src/auth-client/frontend-auth.client.ts +6 -1
- package/src/auth.ts +50 -24
- package/src/cookie.ts +54 -14
- package/src/csrf-token.ts +4 -3
- package/src/generated/internal/class.ts +2 -2
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,
|
|
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,
|
|
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(
|
|
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:
|
|
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,
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
}
|
|
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
|
|
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
|
|
81
|
-
}
|
|
93
|
+
preserveCsrf: boolean;
|
|
94
|
+
}>>): Record<string, string[]>;
|
package/dist/auth.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
}>>
|
|
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
|
-
}>>
|
|
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
|
|
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
|
|
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(`${
|
|
149
|
+
const rawJwt = cookieValue.replace(`${resolvedName}=`, '').replace(';', '');
|
|
135
150
|
const jwt = await parseUserJwt(rawJwt, jwtParams);
|
|
136
151
|
return jwt;
|
|
137
152
|
}
|
package/dist/csrf-token.d.ts
CHANGED
|
@@ -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;
|
package/dist/csrf-token.js
CHANGED
|
@@ -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
|
|
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
|
@@ -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 {
|
|
25
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
76
|
-
jwtParams
|
|
77
|
-
csrfHeaderNameOption
|
|
78
|
-
cookieName
|
|
79
|
-
|
|
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(
|
|
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
|
|
117
|
-
jwtParams
|
|
118
|
-
cookieName
|
|
119
|
-
|
|
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(
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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:
|
|
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:
|
|
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
|
|
211
|
-
jwtParams
|
|
212
|
-
cookieName
|
|
213
|
-
|
|
214
|
-
|
|
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(`${
|
|
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
|
|
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,
|