auth-vir 3.0.1 → 3.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.
- package/dist/auth-client/backend-auth.client.d.ts +14 -0
- package/dist/auth-client/backend-auth.client.js +85 -4
- package/dist/auth-client/frontend-auth.client.js +3 -1
- package/dist/cookie.js +3 -1
- package/dist/csrf-token.js +3 -1
- package/dist/jwt/jwt.js +16 -5
- package/dist/jwt/user-jwt.js +3 -1
- package/dist/mock-local-storage.js +4 -1
- package/package.json +6 -6
- package/src/auth-client/backend-auth.client.ts +132 -4
- package/src/auth-client/frontend-auth.client.ts +3 -1
- package/src/cookie.ts +3 -1
- package/src/csrf-token.ts +3 -1
- package/src/jwt/jwt.ts +16 -5
- package/src/jwt/user-jwt.ts +3 -1
- package/src/mock-local-storage.ts +4 -1
|
@@ -60,6 +60,12 @@ export type BackendAuthClientConfig<DatabaseUser extends AnyObject, UserId exten
|
|
|
60
60
|
*/
|
|
61
61
|
isDev: boolean;
|
|
62
62
|
} & PartialWithUndefined<{
|
|
63
|
+
/** If this returns true, logging will be enabled while handling the relevant session. */
|
|
64
|
+
enableLogging(params: {
|
|
65
|
+
user: DatabaseUser | undefined;
|
|
66
|
+
userId: UserId | undefined;
|
|
67
|
+
assumedUserParams: AssumedUserParams | undefined;
|
|
68
|
+
}): boolean;
|
|
63
69
|
/**
|
|
64
70
|
* Overwrite the header name used for tracking is an admin is assuming the identity of
|
|
65
71
|
* another user.
|
|
@@ -72,6 +78,8 @@ export type BackendAuthClientConfig<DatabaseUser extends AnyObject, UserId exten
|
|
|
72
78
|
generateServiceOrigin(params: {
|
|
73
79
|
requestHeaders: Readonly<IncomingHttpHeaders>;
|
|
74
80
|
}): MaybePromise<undefined | string>;
|
|
81
|
+
/** If provided, logs will be sent to this method. */
|
|
82
|
+
log?: (message: string, extraData: AnyObject) => void;
|
|
75
83
|
/**
|
|
76
84
|
* Set this to allow specific users (determined by `canAssumeUser`) to assume the identity
|
|
77
85
|
* of other users. This should only be used for admins so that they can troubleshoot user
|
|
@@ -141,6 +149,12 @@ export declare class BackendAuthClient<DatabaseUser extends AnyObject, UserId ex
|
|
|
141
149
|
protected readonly config: BackendAuthClientConfig<DatabaseUser, UserId, AssumedUserParams>;
|
|
142
150
|
protected cachedParsedJwtKeys: Record<string, Readonly<JwtKeys>>;
|
|
143
151
|
constructor(config: BackendAuthClientConfig<DatabaseUser, UserId, AssumedUserParams>);
|
|
152
|
+
/** Conditionally logs a message if logging is enabled for the given user context. */
|
|
153
|
+
protected logForUser(params: {
|
|
154
|
+
user: DatabaseUser | undefined;
|
|
155
|
+
userId: UserId | undefined;
|
|
156
|
+
assumedUserParams: AssumedUserParams | undefined;
|
|
157
|
+
}, message: string, extra?: Record<string, unknown>): void;
|
|
144
158
|
/** Get all the parameters used for cookie generation. */
|
|
145
159
|
protected getCookieParams({ isSignUpCookie, requestHeaders, }: {
|
|
146
160
|
/**
|
|
@@ -28,10 +28,27 @@ export class BackendAuthClient {
|
|
|
28
28
|
constructor(config) {
|
|
29
29
|
this.config = config;
|
|
30
30
|
}
|
|
31
|
+
/** Conditionally logs a message if logging is enabled for the given user context. */
|
|
32
|
+
logForUser(params, message, extra) {
|
|
33
|
+
if (this.config.enableLogging?.(params)) {
|
|
34
|
+
const extraData = {
|
|
35
|
+
userId: params.userId,
|
|
36
|
+
...extra,
|
|
37
|
+
};
|
|
38
|
+
if (this.config.log) {
|
|
39
|
+
this.config.log(message, extraData);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
console.info(`[auth-vir] ${message}`, extraData);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
31
46
|
/** Get all the parameters used for cookie generation. */
|
|
32
47
|
async getCookieParams({ isSignUpCookie, requestHeaders, }) {
|
|
33
48
|
const serviceOrigin = requestHeaders
|
|
34
|
-
? await this.config.generateServiceOrigin?.({
|
|
49
|
+
? await this.config.generateServiceOrigin?.({
|
|
50
|
+
requestHeaders,
|
|
51
|
+
})
|
|
35
52
|
: undefined;
|
|
36
53
|
return {
|
|
37
54
|
cookieDuration: this.config.userSessionIdleTimeout || defaultSessionIdleTimeout,
|
|
@@ -53,6 +70,13 @@ export class BackendAuthClient {
|
|
|
53
70
|
requestHeaders,
|
|
54
71
|
});
|
|
55
72
|
if (!authenticatedUser) {
|
|
73
|
+
this.logForUser({
|
|
74
|
+
user: undefined,
|
|
75
|
+
userId,
|
|
76
|
+
assumedUserParams: assumingUser,
|
|
77
|
+
}, 'getUserFromDatabase returned no user', {
|
|
78
|
+
isSignUpCookie,
|
|
79
|
+
});
|
|
56
80
|
return undefined;
|
|
57
81
|
}
|
|
58
82
|
return authenticatedUser;
|
|
@@ -67,6 +91,14 @@ export class BackendAuthClient {
|
|
|
67
91
|
relativeTo: calculateRelativeDate(userIdResult.jwtExpiration, clockSkew),
|
|
68
92
|
});
|
|
69
93
|
if (isExpiredAlready) {
|
|
94
|
+
this.logForUser({
|
|
95
|
+
user: undefined,
|
|
96
|
+
userId: userIdResult.userId,
|
|
97
|
+
assumedUserParams: undefined,
|
|
98
|
+
}, 'Session refresh denied: JWT already expired (even with clock skew tolerance)', {
|
|
99
|
+
jwtExpiration: userIdResult.jwtExpiration,
|
|
100
|
+
now: JSON.stringify(now),
|
|
101
|
+
});
|
|
70
102
|
return undefined;
|
|
71
103
|
}
|
|
72
104
|
/**
|
|
@@ -82,6 +114,15 @@ export class BackendAuthClient {
|
|
|
82
114
|
relativeTo: maxSessionEndDate,
|
|
83
115
|
});
|
|
84
116
|
if (isSessionExpired) {
|
|
117
|
+
this.logForUser({
|
|
118
|
+
user: undefined,
|
|
119
|
+
userId: userIdResult.userId,
|
|
120
|
+
assumedUserParams: undefined,
|
|
121
|
+
}, 'Session refresh denied: max session duration exceeded', {
|
|
122
|
+
sessionStartedAt: userIdResult.sessionStartedAt,
|
|
123
|
+
maxSessionEndDate: JSON.stringify(maxSessionEndDate),
|
|
124
|
+
now: JSON.stringify(now),
|
|
125
|
+
});
|
|
85
126
|
return undefined;
|
|
86
127
|
}
|
|
87
128
|
}
|
|
@@ -112,6 +153,14 @@ export class BackendAuthClient {
|
|
|
112
153
|
};
|
|
113
154
|
}
|
|
114
155
|
else {
|
|
156
|
+
this.logForUser({
|
|
157
|
+
user: undefined,
|
|
158
|
+
userId: userIdResult.userId,
|
|
159
|
+
assumedUserParams: undefined,
|
|
160
|
+
}, 'Session refresh skipped: not yet ready for refresh', {
|
|
161
|
+
jwtIssuedAt: userIdResult.jwtIssuedAt,
|
|
162
|
+
sessionRefreshStartTime,
|
|
163
|
+
});
|
|
115
164
|
return undefined;
|
|
116
165
|
}
|
|
117
166
|
}
|
|
@@ -140,6 +189,13 @@ export class BackendAuthClient {
|
|
|
140
189
|
async getSecureUser({ requestHeaders, isSignUpCookie, allowUserAuthRefresh, }) {
|
|
141
190
|
const userIdResult = await extractUserIdFromRequestHeaders(requestHeaders, await this.getJwtParams(), this.config.csrf, isSignUpCookie ? AuthCookieName.SignUp : AuthCookieName.Auth);
|
|
142
191
|
if (!userIdResult) {
|
|
192
|
+
this.logForUser({
|
|
193
|
+
user: undefined,
|
|
194
|
+
userId: undefined,
|
|
195
|
+
assumedUserParams: undefined,
|
|
196
|
+
}, 'getSecureUser: failed to extract user ID from request headers (invalid JWT, missing cookie, or CSRF mismatch)', {
|
|
197
|
+
isSignUpCookie,
|
|
198
|
+
});
|
|
143
199
|
return undefined;
|
|
144
200
|
}
|
|
145
201
|
const user = await this.getDatabaseUser({
|
|
@@ -149,6 +205,13 @@ export class BackendAuthClient {
|
|
|
149
205
|
requestHeaders,
|
|
150
206
|
});
|
|
151
207
|
if (!user) {
|
|
208
|
+
this.logForUser({
|
|
209
|
+
user: undefined,
|
|
210
|
+
userId: userIdResult.userId,
|
|
211
|
+
assumedUserParams: undefined,
|
|
212
|
+
}, 'getSecureUser: user not found in database', {
|
|
213
|
+
isSignUpCookie,
|
|
214
|
+
});
|
|
152
215
|
return undefined;
|
|
153
216
|
}
|
|
154
217
|
const assumedUser = await this.getAssumedUser({
|
|
@@ -175,7 +238,9 @@ export class BackendAuthClient {
|
|
|
175
238
|
const cachedParsedKeys = this.cachedParsedJwtKeys[cacheKey];
|
|
176
239
|
const parsedKeys = cachedParsedKeys || (await parseJwtKeys(rawJwtKeys));
|
|
177
240
|
if (!cachedParsedKeys) {
|
|
178
|
-
this.cachedParsedJwtKeys = {
|
|
241
|
+
this.cachedParsedJwtKeys = {
|
|
242
|
+
[cacheKey]: parsedKeys,
|
|
243
|
+
};
|
|
179
244
|
}
|
|
180
245
|
return {
|
|
181
246
|
jwtKeys: parsedKeys,
|
|
@@ -241,11 +306,17 @@ export class BackendAuthClient {
|
|
|
241
306
|
async getInsecureOrSecureUser(params) {
|
|
242
307
|
const secureUser = await this.getSecureUser(params);
|
|
243
308
|
if (secureUser) {
|
|
244
|
-
return {
|
|
309
|
+
return {
|
|
310
|
+
secureUser,
|
|
311
|
+
};
|
|
245
312
|
}
|
|
246
313
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
247
314
|
const insecureUser = await this.getInsecureUser(params);
|
|
248
|
-
return insecureUser
|
|
315
|
+
return insecureUser
|
|
316
|
+
? {
|
|
317
|
+
insecureUser,
|
|
318
|
+
}
|
|
319
|
+
: {};
|
|
249
320
|
}
|
|
250
321
|
/**
|
|
251
322
|
* @deprecated This only half authenticates the user. It should only be used in circumstances
|
|
@@ -256,6 +327,11 @@ export class BackendAuthClient {
|
|
|
256
327
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
257
328
|
const userIdResult = await insecureExtractUserIdFromCookieAlone(requestHeaders, await this.getJwtParams(), AuthCookieName.Auth);
|
|
258
329
|
if (!userIdResult) {
|
|
330
|
+
this.logForUser({
|
|
331
|
+
user: undefined,
|
|
332
|
+
userId: undefined,
|
|
333
|
+
assumedUserParams: undefined,
|
|
334
|
+
}, 'getInsecureUser: failed to extract user ID from cookie (invalid JWT or missing cookie)');
|
|
259
335
|
return undefined;
|
|
260
336
|
}
|
|
261
337
|
const user = await this.getDatabaseUser({
|
|
@@ -265,6 +341,11 @@ export class BackendAuthClient {
|
|
|
265
341
|
requestHeaders,
|
|
266
342
|
});
|
|
267
343
|
if (!user) {
|
|
344
|
+
this.logForUser({
|
|
345
|
+
user: undefined,
|
|
346
|
+
userId: userIdResult.userId,
|
|
347
|
+
assumedUserParams: undefined,
|
|
348
|
+
}, 'getInsecureUser: user not found in database');
|
|
268
349
|
return undefined;
|
|
269
350
|
}
|
|
270
351
|
const refreshHeaders = allowUserAuthRefresh &&
|
package/dist/cookie.js
CHANGED
|
@@ -29,7 +29,9 @@ export async function generateAuthCookie(userJwtData, cookieConfig) {
|
|
|
29
29
|
HttpOnly: true,
|
|
30
30
|
Path: '/',
|
|
31
31
|
SameSite: 'Strict',
|
|
32
|
-
'MAX-AGE': convertDuration(cookieConfig.cookieDuration, {
|
|
32
|
+
'MAX-AGE': convertDuration(cookieConfig.cookieDuration, {
|
|
33
|
+
seconds: true,
|
|
34
|
+
}).seconds,
|
|
33
35
|
Secure: !cookieConfig.isDev,
|
|
34
36
|
}),
|
|
35
37
|
expiration,
|
package/dist/csrf-token.js
CHANGED
|
@@ -17,7 +17,9 @@ export const csrfTokenShape = defineShape({
|
|
|
17
17
|
* @category Internal
|
|
18
18
|
* @default {minutes: 5}
|
|
19
19
|
*/
|
|
20
|
-
export const defaultAllowedClockSkew = {
|
|
20
|
+
export const defaultAllowedClockSkew = {
|
|
21
|
+
minutes: 5,
|
|
22
|
+
};
|
|
21
23
|
/**
|
|
22
24
|
* Generates a random, cryptographically secure CSRF token.
|
|
23
25
|
*
|
package/dist/jwt/jwt.js
CHANGED
|
@@ -2,8 +2,13 @@ import { assertWrap, check } from '@augment-vir/assert';
|
|
|
2
2
|
import { calculateRelativeDate, convertDuration, createFullDateInUserTimezone, createUtcFullDate, getNowInUtcTimezone, toTimestamp, } from 'date-vir';
|
|
3
3
|
import { EncryptJWT, jwtDecrypt, jwtVerify, SignJWT } from 'jose';
|
|
4
4
|
import { defaultAllowedClockSkew } from '../csrf-token.js';
|
|
5
|
-
const encryptionProtectedHeader = {
|
|
6
|
-
|
|
5
|
+
const encryptionProtectedHeader = {
|
|
6
|
+
alg: 'dir',
|
|
7
|
+
enc: 'A256GCM',
|
|
8
|
+
};
|
|
9
|
+
const signingProtectedHeader = {
|
|
10
|
+
alg: 'HS512',
|
|
11
|
+
};
|
|
7
12
|
/**
|
|
8
13
|
* JWT uses seconds since the epoch per RFC 7519, whereas `toTimestamp` uses milliseconds.
|
|
9
14
|
*
|
|
@@ -28,7 +33,9 @@ export function parseJwtTimestamp(seconds) {
|
|
|
28
33
|
export async function createJwt(
|
|
29
34
|
/** The data to be included in the JWT. */
|
|
30
35
|
data, params) {
|
|
31
|
-
const rawJwt = new SignJWT({
|
|
36
|
+
const rawJwt = new SignJWT({
|
|
37
|
+
data,
|
|
38
|
+
})
|
|
32
39
|
.setProtectedHeader(signingProtectedHeader)
|
|
33
40
|
.setIssuedAt(params.issuedAt
|
|
34
41
|
? toJwtTimestamp(createFullDateInUserTimezone(params.issuedAt))
|
|
@@ -40,7 +47,9 @@ data, params) {
|
|
|
40
47
|
rawJwt.setNotBefore(toJwtTimestamp(createFullDateInUserTimezone(params.notValidUntil)));
|
|
41
48
|
}
|
|
42
49
|
const signedJwt = await rawJwt.sign(params.jwtKeys.signingKey);
|
|
43
|
-
return await new EncryptJWT({
|
|
50
|
+
return await new EncryptJWT({
|
|
51
|
+
jwt: signedJwt,
|
|
52
|
+
})
|
|
44
53
|
.setProtectedHeader(encryptionProtectedHeader)
|
|
45
54
|
.encrypt(params.jwtKeys.encryptionKey);
|
|
46
55
|
}
|
|
@@ -58,7 +67,9 @@ export async function parseJwt(encryptedJwt, params) {
|
|
|
58
67
|
else if (!check.isString(decryptedJwt.payload.jwt)) {
|
|
59
68
|
throw new TypeError('Decrypted jwt is not a string.');
|
|
60
69
|
}
|
|
61
|
-
const clockToleranceSeconds = convertDuration(params.allowedClockSkew || defaultAllowedClockSkew, {
|
|
70
|
+
const clockToleranceSeconds = convertDuration(params.allowedClockSkew || defaultAllowedClockSkew, {
|
|
71
|
+
seconds: true,
|
|
72
|
+
}).seconds;
|
|
62
73
|
const verifiedJwt = await jwtVerify(decryptedJwt.payload.jwt, params.jwtKeys.signingKey, {
|
|
63
74
|
issuer: params.issuer,
|
|
64
75
|
audience: params.audience,
|
package/dist/jwt/user-jwt.js
CHANGED
|
@@ -19,7 +19,9 @@ export const userJwtDataShape = defineShape({
|
|
|
19
19
|
* enforce the max session duration. If not present, the session is considered to have started
|
|
20
20
|
* when the JWT was issued.
|
|
21
21
|
*/
|
|
22
|
-
sessionStartedAt: optionalShape(0, {
|
|
22
|
+
sessionStartedAt: optionalShape(0, {
|
|
23
|
+
alsoUndefined: true,
|
|
24
|
+
}),
|
|
23
25
|
});
|
|
24
26
|
/**
|
|
25
27
|
* Creates a new signed and encrypted {@link JwtUserData} when a client (frontend) successfully
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auth-vir",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "Auth made easy and secure via JWT cookies, CSRF tokens, and password hashing helpers.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"auth",
|
|
@@ -41,9 +41,9 @@
|
|
|
41
41
|
"test:web": "virmator test web"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@augment-vir/assert": "^31.
|
|
45
|
-
"@augment-vir/common": "^31.
|
|
46
|
-
"date-vir": "^8.
|
|
44
|
+
"@augment-vir/assert": "^31.67.1",
|
|
45
|
+
"@augment-vir/common": "^31.67.1",
|
|
46
|
+
"date-vir": "^8.2.0",
|
|
47
47
|
"detect-activity": "^1.0.0",
|
|
48
48
|
"hash-wasm": "^4.12.0",
|
|
49
49
|
"jose": "^6.1.3",
|
|
@@ -52,9 +52,9 @@
|
|
|
52
52
|
"url-vir": "^2.1.7"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"@augment-vir/test": "^31.
|
|
55
|
+
"@augment-vir/test": "^31.67.1",
|
|
56
56
|
"@prisma/client": "^6.19.2",
|
|
57
|
-
"@types/node": "^25.3.
|
|
57
|
+
"@types/node": "^25.3.3",
|
|
58
58
|
"@web/dev-server-esbuild": "^1.0.5",
|
|
59
59
|
"@web/test-runner": "^0.20.2",
|
|
60
60
|
"@web/test-runner-commands": "^0.9.0",
|
|
@@ -91,6 +91,12 @@ export type BackendAuthClientConfig<
|
|
|
91
91
|
*/
|
|
92
92
|
isDev: boolean;
|
|
93
93
|
} & PartialWithUndefined<{
|
|
94
|
+
/** If this returns true, logging will be enabled while handling the relevant session. */
|
|
95
|
+
enableLogging(params: {
|
|
96
|
+
user: DatabaseUser | undefined;
|
|
97
|
+
userId: UserId | undefined;
|
|
98
|
+
assumedUserParams: AssumedUserParams | undefined;
|
|
99
|
+
}): boolean;
|
|
94
100
|
/**
|
|
95
101
|
* Overwrite the header name used for tracking is an admin is assuming the identity of
|
|
96
102
|
* another user.
|
|
@@ -103,6 +109,8 @@ export type BackendAuthClientConfig<
|
|
|
103
109
|
generateServiceOrigin(params: {
|
|
104
110
|
requestHeaders: Readonly<IncomingHttpHeaders>;
|
|
105
111
|
}): MaybePromise<undefined | string>;
|
|
112
|
+
/** If provided, logs will be sent to this method. */
|
|
113
|
+
log?: (message: string, extraData: AnyObject) => void;
|
|
106
114
|
/**
|
|
107
115
|
* Set this to allow specific users (determined by `canAssumeUser`) to assume the identity
|
|
108
116
|
* of other users. This should only be used for admins so that they can troubleshoot user
|
|
@@ -197,6 +205,30 @@ export class BackendAuthClient<
|
|
|
197
205
|
protected readonly config: BackendAuthClientConfig<DatabaseUser, UserId, AssumedUserParams>,
|
|
198
206
|
) {}
|
|
199
207
|
|
|
208
|
+
/** Conditionally logs a message if logging is enabled for the given user context. */
|
|
209
|
+
protected logForUser(
|
|
210
|
+
params: {
|
|
211
|
+
user: DatabaseUser | undefined;
|
|
212
|
+
userId: UserId | undefined;
|
|
213
|
+
assumedUserParams: AssumedUserParams | undefined;
|
|
214
|
+
},
|
|
215
|
+
message: string,
|
|
216
|
+
extra?: Record<string, unknown>,
|
|
217
|
+
): void {
|
|
218
|
+
if (this.config.enableLogging?.(params)) {
|
|
219
|
+
const extraData = {
|
|
220
|
+
userId: params.userId,
|
|
221
|
+
...extra,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
if (this.config.log) {
|
|
225
|
+
this.config.log(message, extraData);
|
|
226
|
+
} else {
|
|
227
|
+
console.info(`[auth-vir] ${message}`, extraData);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
200
232
|
/** Get all the parameters used for cookie generation. */
|
|
201
233
|
protected async getCookieParams({
|
|
202
234
|
isSignUpCookie,
|
|
@@ -212,7 +244,9 @@ export class BackendAuthClient<
|
|
|
212
244
|
requestHeaders: Readonly<IncomingHttpHeaders> | undefined;
|
|
213
245
|
}): Promise<Readonly<CookieParams>> {
|
|
214
246
|
const serviceOrigin = requestHeaders
|
|
215
|
-
? await this.config.generateServiceOrigin?.({
|
|
247
|
+
? await this.config.generateServiceOrigin?.({
|
|
248
|
+
requestHeaders,
|
|
249
|
+
})
|
|
216
250
|
: undefined;
|
|
217
251
|
|
|
218
252
|
return {
|
|
@@ -248,6 +282,17 @@ export class BackendAuthClient<
|
|
|
248
282
|
});
|
|
249
283
|
|
|
250
284
|
if (!authenticatedUser) {
|
|
285
|
+
this.logForUser(
|
|
286
|
+
{
|
|
287
|
+
user: undefined,
|
|
288
|
+
userId,
|
|
289
|
+
assumedUserParams: assumingUser,
|
|
290
|
+
},
|
|
291
|
+
'getUserFromDatabase returned no user',
|
|
292
|
+
{
|
|
293
|
+
isSignUpCookie,
|
|
294
|
+
},
|
|
295
|
+
);
|
|
251
296
|
return undefined;
|
|
252
297
|
}
|
|
253
298
|
|
|
@@ -273,6 +318,18 @@ export class BackendAuthClient<
|
|
|
273
318
|
});
|
|
274
319
|
|
|
275
320
|
if (isExpiredAlready) {
|
|
321
|
+
this.logForUser(
|
|
322
|
+
{
|
|
323
|
+
user: undefined,
|
|
324
|
+
userId: userIdResult.userId,
|
|
325
|
+
assumedUserParams: undefined,
|
|
326
|
+
},
|
|
327
|
+
'Session refresh denied: JWT already expired (even with clock skew tolerance)',
|
|
328
|
+
{
|
|
329
|
+
jwtExpiration: userIdResult.jwtExpiration,
|
|
330
|
+
now: JSON.stringify(now),
|
|
331
|
+
},
|
|
332
|
+
);
|
|
276
333
|
return undefined;
|
|
277
334
|
}
|
|
278
335
|
|
|
@@ -290,6 +347,19 @@ export class BackendAuthClient<
|
|
|
290
347
|
});
|
|
291
348
|
|
|
292
349
|
if (isSessionExpired) {
|
|
350
|
+
this.logForUser(
|
|
351
|
+
{
|
|
352
|
+
user: undefined,
|
|
353
|
+
userId: userIdResult.userId,
|
|
354
|
+
assumedUserParams: undefined,
|
|
355
|
+
},
|
|
356
|
+
'Session refresh denied: max session duration exceeded',
|
|
357
|
+
{
|
|
358
|
+
sessionStartedAt: userIdResult.sessionStartedAt,
|
|
359
|
+
maxSessionEndDate: JSON.stringify(maxSessionEndDate),
|
|
360
|
+
now: JSON.stringify(now),
|
|
361
|
+
},
|
|
362
|
+
);
|
|
293
363
|
return undefined;
|
|
294
364
|
}
|
|
295
365
|
}
|
|
@@ -327,6 +397,18 @@ export class BackendAuthClient<
|
|
|
327
397
|
}),
|
|
328
398
|
};
|
|
329
399
|
} else {
|
|
400
|
+
this.logForUser(
|
|
401
|
+
{
|
|
402
|
+
user: undefined,
|
|
403
|
+
userId: userIdResult.userId,
|
|
404
|
+
assumedUserParams: undefined,
|
|
405
|
+
},
|
|
406
|
+
'Session refresh skipped: not yet ready for refresh',
|
|
407
|
+
{
|
|
408
|
+
jwtIssuedAt: userIdResult.jwtIssuedAt,
|
|
409
|
+
sessionRefreshStartTime,
|
|
410
|
+
},
|
|
411
|
+
);
|
|
330
412
|
return undefined;
|
|
331
413
|
}
|
|
332
414
|
}
|
|
@@ -390,6 +472,17 @@ export class BackendAuthClient<
|
|
|
390
472
|
isSignUpCookie ? AuthCookieName.SignUp : AuthCookieName.Auth,
|
|
391
473
|
);
|
|
392
474
|
if (!userIdResult) {
|
|
475
|
+
this.logForUser(
|
|
476
|
+
{
|
|
477
|
+
user: undefined,
|
|
478
|
+
userId: undefined,
|
|
479
|
+
assumedUserParams: undefined,
|
|
480
|
+
},
|
|
481
|
+
'getSecureUser: failed to extract user ID from request headers (invalid JWT, missing cookie, or CSRF mismatch)',
|
|
482
|
+
{
|
|
483
|
+
isSignUpCookie,
|
|
484
|
+
},
|
|
485
|
+
);
|
|
393
486
|
return undefined;
|
|
394
487
|
}
|
|
395
488
|
|
|
@@ -401,6 +494,17 @@ export class BackendAuthClient<
|
|
|
401
494
|
});
|
|
402
495
|
|
|
403
496
|
if (!user) {
|
|
497
|
+
this.logForUser(
|
|
498
|
+
{
|
|
499
|
+
user: undefined,
|
|
500
|
+
userId: userIdResult.userId,
|
|
501
|
+
assumedUserParams: undefined,
|
|
502
|
+
},
|
|
503
|
+
'getSecureUser: user not found in database',
|
|
504
|
+
{
|
|
505
|
+
isSignUpCookie,
|
|
506
|
+
},
|
|
507
|
+
);
|
|
404
508
|
return undefined;
|
|
405
509
|
}
|
|
406
510
|
|
|
@@ -435,7 +539,9 @@ export class BackendAuthClient<
|
|
|
435
539
|
const parsedKeys = cachedParsedKeys || (await parseJwtKeys(rawJwtKeys));
|
|
436
540
|
|
|
437
541
|
if (!cachedParsedKeys) {
|
|
438
|
-
this.cachedParsedJwtKeys = {
|
|
542
|
+
this.cachedParsedJwtKeys = {
|
|
543
|
+
[cacheKey]: parsedKeys,
|
|
544
|
+
};
|
|
439
545
|
}
|
|
440
546
|
return {
|
|
441
547
|
jwtKeys: parsedKeys,
|
|
@@ -584,13 +690,19 @@ export class BackendAuthClient<
|
|
|
584
690
|
const secureUser = await this.getSecureUser(params);
|
|
585
691
|
|
|
586
692
|
if (secureUser) {
|
|
587
|
-
return {
|
|
693
|
+
return {
|
|
694
|
+
secureUser,
|
|
695
|
+
};
|
|
588
696
|
}
|
|
589
697
|
|
|
590
698
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
591
699
|
const insecureUser = await this.getInsecureUser(params);
|
|
592
700
|
|
|
593
|
-
return insecureUser
|
|
701
|
+
return insecureUser
|
|
702
|
+
? {
|
|
703
|
+
insecureUser,
|
|
704
|
+
}
|
|
705
|
+
: {};
|
|
594
706
|
}
|
|
595
707
|
|
|
596
708
|
/**
|
|
@@ -618,6 +730,14 @@ export class BackendAuthClient<
|
|
|
618
730
|
);
|
|
619
731
|
|
|
620
732
|
if (!userIdResult) {
|
|
733
|
+
this.logForUser(
|
|
734
|
+
{
|
|
735
|
+
user: undefined,
|
|
736
|
+
userId: undefined,
|
|
737
|
+
assumedUserParams: undefined,
|
|
738
|
+
},
|
|
739
|
+
'getInsecureUser: failed to extract user ID from cookie (invalid JWT or missing cookie)',
|
|
740
|
+
);
|
|
621
741
|
return undefined;
|
|
622
742
|
}
|
|
623
743
|
|
|
@@ -629,6 +749,14 @@ export class BackendAuthClient<
|
|
|
629
749
|
});
|
|
630
750
|
|
|
631
751
|
if (!user) {
|
|
752
|
+
this.logForUser(
|
|
753
|
+
{
|
|
754
|
+
user: undefined,
|
|
755
|
+
userId: userIdResult.userId,
|
|
756
|
+
assumedUserParams: undefined,
|
|
757
|
+
},
|
|
758
|
+
'getInsecureUser: user not found in database',
|
|
759
|
+
);
|
|
632
760
|
return undefined;
|
|
633
761
|
}
|
|
634
762
|
|
|
@@ -112,7 +112,9 @@ export class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject =
|
|
|
112
112
|
});
|
|
113
113
|
}
|
|
114
114
|
},
|
|
115
|
-
debounce: config.checkUser.debounce || {
|
|
115
|
+
debounce: config.checkUser.debounce || {
|
|
116
|
+
minutes: 1,
|
|
117
|
+
},
|
|
116
118
|
fireImmediately: false,
|
|
117
119
|
});
|
|
118
120
|
}
|
package/src/cookie.ts
CHANGED
|
@@ -91,7 +91,9 @@ export async function generateAuthCookie(
|
|
|
91
91
|
HttpOnly: true,
|
|
92
92
|
Path: '/',
|
|
93
93
|
SameSite: 'Strict',
|
|
94
|
-
'MAX-AGE': convertDuration(cookieConfig.cookieDuration, {
|
|
94
|
+
'MAX-AGE': convertDuration(cookieConfig.cookieDuration, {
|
|
95
|
+
seconds: true,
|
|
96
|
+
}).seconds,
|
|
95
97
|
Secure: !cookieConfig.isDev,
|
|
96
98
|
}),
|
|
97
99
|
expiration,
|
package/src/csrf-token.ts
CHANGED
|
@@ -38,7 +38,9 @@ export type CsrfToken = typeof csrfTokenShape.runtimeType;
|
|
|
38
38
|
* @category Internal
|
|
39
39
|
* @default {minutes: 5}
|
|
40
40
|
*/
|
|
41
|
-
export const defaultAllowedClockSkew: Readonly<AnyDuration> = {
|
|
41
|
+
export const defaultAllowedClockSkew: Readonly<AnyDuration> = {
|
|
42
|
+
minutes: 5,
|
|
43
|
+
};
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
46
|
* Generates a random, cryptographically secure CSRF token.
|
package/src/jwt/jwt.ts
CHANGED
|
@@ -16,8 +16,13 @@ import {EncryptJWT, jwtDecrypt, jwtVerify, SignJWT} from 'jose';
|
|
|
16
16
|
import {defaultAllowedClockSkew} from '../csrf-token.js';
|
|
17
17
|
import {type JwtKeys} from './jwt-keys.js';
|
|
18
18
|
|
|
19
|
-
const encryptionProtectedHeader = {
|
|
20
|
-
|
|
19
|
+
const encryptionProtectedHeader = {
|
|
20
|
+
alg: 'dir',
|
|
21
|
+
enc: 'A256GCM',
|
|
22
|
+
};
|
|
23
|
+
const signingProtectedHeader = {
|
|
24
|
+
alg: 'HS512',
|
|
25
|
+
};
|
|
21
26
|
|
|
22
27
|
/**
|
|
23
28
|
* Params for {@link createJwt}.
|
|
@@ -110,7 +115,9 @@ export async function createJwt<JwtData extends AnyObject = AnyObject>(
|
|
|
110
115
|
data: JwtData,
|
|
111
116
|
params: Readonly<CreateJwtParams>,
|
|
112
117
|
): Promise<string> {
|
|
113
|
-
const rawJwt = new SignJWT({
|
|
118
|
+
const rawJwt = new SignJWT({
|
|
119
|
+
data,
|
|
120
|
+
})
|
|
114
121
|
.setProtectedHeader(signingProtectedHeader)
|
|
115
122
|
.setIssuedAt(
|
|
116
123
|
params.issuedAt
|
|
@@ -129,7 +136,9 @@ export async function createJwt<JwtData extends AnyObject = AnyObject>(
|
|
|
129
136
|
|
|
130
137
|
const signedJwt = await rawJwt.sign(params.jwtKeys.signingKey);
|
|
131
138
|
|
|
132
|
-
return await new EncryptJWT({
|
|
139
|
+
return await new EncryptJWT({
|
|
140
|
+
jwt: signedJwt,
|
|
141
|
+
})
|
|
133
142
|
.setProtectedHeader(encryptionProtectedHeader)
|
|
134
143
|
.encrypt(params.jwtKeys.encryptionKey);
|
|
135
144
|
}
|
|
@@ -182,7 +191,9 @@ export async function parseJwt<JwtData extends AnyObject = AnyObject>(
|
|
|
182
191
|
|
|
183
192
|
const clockToleranceSeconds = convertDuration(
|
|
184
193
|
params.allowedClockSkew || defaultAllowedClockSkew,
|
|
185
|
-
{
|
|
194
|
+
{
|
|
195
|
+
seconds: true,
|
|
196
|
+
},
|
|
186
197
|
).seconds;
|
|
187
198
|
|
|
188
199
|
const verifiedJwt = await jwtVerify(decryptedJwt.payload.jwt, params.jwtKeys.signingKey, {
|
package/src/jwt/user-jwt.ts
CHANGED
|
@@ -27,7 +27,9 @@ export const userJwtDataShape = defineShape({
|
|
|
27
27
|
* enforce the max session duration. If not present, the session is considered to have started
|
|
28
28
|
* when the JWT was issued.
|
|
29
29
|
*/
|
|
30
|
-
sessionStartedAt: optionalShape(0, {
|
|
30
|
+
sessionStartedAt: optionalShape(0, {
|
|
31
|
+
alsoUndefined: true,
|
|
32
|
+
}),
|
|
31
33
|
});
|
|
32
34
|
|
|
33
35
|
/**
|