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