auth-vir 3.0.0 → 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 +19 -3
- package/dist/auth-client/backend-auth.client.js +93 -8
- 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 +143 -8
- 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
|
@@ -46,6 +46,7 @@ export type BackendAuthClientConfig<DatabaseUser extends AnyObject, UserId exten
|
|
|
46
46
|
* their user identity. Otherwise, this is `undefined`.
|
|
47
47
|
*/
|
|
48
48
|
assumingUser: AssumedUserParams | undefined;
|
|
49
|
+
requestHeaders: Readonly<IncomingHttpHeaders>;
|
|
49
50
|
}) => MaybePromise<DatabaseUser | undefined | null>;
|
|
50
51
|
/**
|
|
51
52
|
* Get JWT keys produced by {@link generateNewJwtKeys}. Make sure that each time this is
|
|
@@ -59,6 +60,12 @@ export type BackendAuthClientConfig<DatabaseUser extends AnyObject, UserId exten
|
|
|
59
60
|
*/
|
|
60
61
|
isDev: boolean;
|
|
61
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;
|
|
62
69
|
/**
|
|
63
70
|
* Overwrite the header name used for tracking is an admin is assuming the identity of
|
|
64
71
|
* another user.
|
|
@@ -71,6 +78,8 @@ export type BackendAuthClientConfig<DatabaseUser extends AnyObject, UserId exten
|
|
|
71
78
|
generateServiceOrigin(params: {
|
|
72
79
|
requestHeaders: Readonly<IncomingHttpHeaders>;
|
|
73
80
|
}): MaybePromise<undefined | string>;
|
|
81
|
+
/** If provided, logs will be sent to this method. */
|
|
82
|
+
log?: (message: string, extraData: AnyObject) => void;
|
|
74
83
|
/**
|
|
75
84
|
* Set this to allow specific users (determined by `canAssumeUser`) to assume the identity
|
|
76
85
|
* of other users. This should only be used for admins so that they can troubleshoot user
|
|
@@ -140,6 +149,12 @@ export declare class BackendAuthClient<DatabaseUser extends AnyObject, UserId ex
|
|
|
140
149
|
protected readonly config: BackendAuthClientConfig<DatabaseUser, UserId, AssumedUserParams>;
|
|
141
150
|
protected cachedParsedJwtKeys: Record<string, Readonly<JwtKeys>>;
|
|
142
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;
|
|
143
158
|
/** Get all the parameters used for cookie generation. */
|
|
144
159
|
protected getCookieParams({ isSignUpCookie, requestHeaders, }: {
|
|
145
160
|
/**
|
|
@@ -152,10 +167,11 @@ export declare class BackendAuthClient<DatabaseUser extends AnyObject, UserId ex
|
|
|
152
167
|
requestHeaders: Readonly<IncomingHttpHeaders> | undefined;
|
|
153
168
|
}): Promise<Readonly<CookieParams>>;
|
|
154
169
|
/** Calls the provided `getUserFromDatabase` config. */
|
|
155
|
-
protected getDatabaseUser({ isSignUpCookie, userId, assumingUser, }: {
|
|
170
|
+
protected getDatabaseUser({ isSignUpCookie, userId, assumingUser, requestHeaders, }: {
|
|
156
171
|
userId: UserId | undefined;
|
|
157
172
|
assumingUser: AssumedUserParams | undefined;
|
|
158
173
|
isSignUpCookie: boolean;
|
|
174
|
+
requestHeaders: IncomingHttpHeaders;
|
|
159
175
|
}): Promise<undefined | DatabaseUser>;
|
|
160
176
|
/** Creates a `'cookie-set'` header to refresh the user's session cookie. */
|
|
161
177
|
protected createCookieRefreshHeaders({ userIdResult, requestHeaders, }: {
|
|
@@ -163,9 +179,9 @@ export declare class BackendAuthClient<DatabaseUser extends AnyObject, UserId ex
|
|
|
163
179
|
requestHeaders: IncomingHttpHeaders;
|
|
164
180
|
}): Promise<OutgoingHttpHeaders | undefined>;
|
|
165
181
|
/** Reads the user's assumed user headers and, if configured, gets the assumed user. */
|
|
166
|
-
protected getAssumedUser({
|
|
182
|
+
protected getAssumedUser({ requestHeaders, user, }: {
|
|
167
183
|
user: DatabaseUser;
|
|
168
|
-
|
|
184
|
+
requestHeaders: IncomingHttpHeaders;
|
|
169
185
|
}): Promise<DatabaseUser | undefined>;
|
|
170
186
|
/** Securely extract a user from their request headers. */
|
|
171
187
|
getSecureUser({ requestHeaders, isSignUpCookie, allowUserAuthRefresh, }: {
|
|
@@ -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,
|
|
@@ -42,7 +59,7 @@ export class BackendAuthClient {
|
|
|
42
59
|
};
|
|
43
60
|
}
|
|
44
61
|
/** Calls the provided `getUserFromDatabase` config. */
|
|
45
|
-
async getDatabaseUser({ isSignUpCookie, userId, assumingUser, }) {
|
|
62
|
+
async getDatabaseUser({ isSignUpCookie, userId, assumingUser, requestHeaders, }) {
|
|
46
63
|
if (!userId) {
|
|
47
64
|
return undefined;
|
|
48
65
|
}
|
|
@@ -50,8 +67,16 @@ export class BackendAuthClient {
|
|
|
50
67
|
assumingUser,
|
|
51
68
|
userId,
|
|
52
69
|
isSignUpCookie,
|
|
70
|
+
requestHeaders,
|
|
53
71
|
});
|
|
54
72
|
if (!authenticatedUser) {
|
|
73
|
+
this.logForUser({
|
|
74
|
+
user: undefined,
|
|
75
|
+
userId,
|
|
76
|
+
assumedUserParams: assumingUser,
|
|
77
|
+
}, 'getUserFromDatabase returned no user', {
|
|
78
|
+
isSignUpCookie,
|
|
79
|
+
});
|
|
55
80
|
return undefined;
|
|
56
81
|
}
|
|
57
82
|
return authenticatedUser;
|
|
@@ -66,6 +91,14 @@ export class BackendAuthClient {
|
|
|
66
91
|
relativeTo: calculateRelativeDate(userIdResult.jwtExpiration, clockSkew),
|
|
67
92
|
});
|
|
68
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
|
+
});
|
|
69
102
|
return undefined;
|
|
70
103
|
}
|
|
71
104
|
/**
|
|
@@ -81,6 +114,15 @@ export class BackendAuthClient {
|
|
|
81
114
|
relativeTo: maxSessionEndDate,
|
|
82
115
|
});
|
|
83
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
|
+
});
|
|
84
126
|
return undefined;
|
|
85
127
|
}
|
|
86
128
|
}
|
|
@@ -111,15 +153,23 @@ export class BackendAuthClient {
|
|
|
111
153
|
};
|
|
112
154
|
}
|
|
113
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
|
+
});
|
|
114
164
|
return undefined;
|
|
115
165
|
}
|
|
116
166
|
}
|
|
117
167
|
/** Reads the user's assumed user headers and, if configured, gets the assumed user. */
|
|
118
|
-
async getAssumedUser({
|
|
168
|
+
async getAssumedUser({ requestHeaders, user, }) {
|
|
119
169
|
if (!this.config.assumeUser || !(await this.config.assumeUser.canAssumeUser(user))) {
|
|
120
170
|
return undefined;
|
|
121
171
|
}
|
|
122
|
-
const assumedUserHeader = ensureArray(
|
|
172
|
+
const assumedUserHeader = ensureArray(requestHeaders[this.config.assumedUserHeaderName || AuthHeaderName.AssumedUser])[0];
|
|
123
173
|
if (!assumedUserHeader) {
|
|
124
174
|
return undefined;
|
|
125
175
|
}
|
|
@@ -131,6 +181,7 @@ export class BackendAuthClient {
|
|
|
131
181
|
isSignUpCookie: false,
|
|
132
182
|
userId: parsedAssumedUserData.userId,
|
|
133
183
|
assumingUser: parsedAssumedUserData.assumedUserParams,
|
|
184
|
+
requestHeaders,
|
|
134
185
|
});
|
|
135
186
|
return assumedUser;
|
|
136
187
|
}
|
|
@@ -138,18 +189,33 @@ export class BackendAuthClient {
|
|
|
138
189
|
async getSecureUser({ requestHeaders, isSignUpCookie, allowUserAuthRefresh, }) {
|
|
139
190
|
const userIdResult = await extractUserIdFromRequestHeaders(requestHeaders, await this.getJwtParams(), this.config.csrf, isSignUpCookie ? AuthCookieName.SignUp : AuthCookieName.Auth);
|
|
140
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
|
+
});
|
|
141
199
|
return undefined;
|
|
142
200
|
}
|
|
143
201
|
const user = await this.getDatabaseUser({
|
|
144
202
|
userId: userIdResult.userId,
|
|
145
203
|
assumingUser: undefined,
|
|
146
204
|
isSignUpCookie,
|
|
205
|
+
requestHeaders,
|
|
147
206
|
});
|
|
148
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
|
+
});
|
|
149
215
|
return undefined;
|
|
150
216
|
}
|
|
151
217
|
const assumedUser = await this.getAssumedUser({
|
|
152
|
-
|
|
218
|
+
requestHeaders,
|
|
153
219
|
user,
|
|
154
220
|
});
|
|
155
221
|
const cookieRefreshHeaders = (await this.createCookieRefreshHeaders({
|
|
@@ -172,7 +238,9 @@ export class BackendAuthClient {
|
|
|
172
238
|
const cachedParsedKeys = this.cachedParsedJwtKeys[cacheKey];
|
|
173
239
|
const parsedKeys = cachedParsedKeys || (await parseJwtKeys(rawJwtKeys));
|
|
174
240
|
if (!cachedParsedKeys) {
|
|
175
|
-
this.cachedParsedJwtKeys = {
|
|
241
|
+
this.cachedParsedJwtKeys = {
|
|
242
|
+
[cacheKey]: parsedKeys,
|
|
243
|
+
};
|
|
176
244
|
}
|
|
177
245
|
return {
|
|
178
246
|
jwtKeys: parsedKeys,
|
|
@@ -238,11 +306,17 @@ export class BackendAuthClient {
|
|
|
238
306
|
async getInsecureOrSecureUser(params) {
|
|
239
307
|
const secureUser = await this.getSecureUser(params);
|
|
240
308
|
if (secureUser) {
|
|
241
|
-
return {
|
|
309
|
+
return {
|
|
310
|
+
secureUser,
|
|
311
|
+
};
|
|
242
312
|
}
|
|
243
313
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
244
314
|
const insecureUser = await this.getInsecureUser(params);
|
|
245
|
-
return insecureUser
|
|
315
|
+
return insecureUser
|
|
316
|
+
? {
|
|
317
|
+
insecureUser,
|
|
318
|
+
}
|
|
319
|
+
: {};
|
|
246
320
|
}
|
|
247
321
|
/**
|
|
248
322
|
* @deprecated This only half authenticates the user. It should only be used in circumstances
|
|
@@ -253,14 +327,25 @@ export class BackendAuthClient {
|
|
|
253
327
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
254
328
|
const userIdResult = await insecureExtractUserIdFromCookieAlone(requestHeaders, await this.getJwtParams(), AuthCookieName.Auth);
|
|
255
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)');
|
|
256
335
|
return undefined;
|
|
257
336
|
}
|
|
258
337
|
const user = await this.getDatabaseUser({
|
|
259
338
|
isSignUpCookie: false,
|
|
260
339
|
userId: userIdResult.userId,
|
|
261
340
|
assumingUser: undefined,
|
|
341
|
+
requestHeaders,
|
|
262
342
|
});
|
|
263
343
|
if (!user) {
|
|
344
|
+
this.logForUser({
|
|
345
|
+
user: undefined,
|
|
346
|
+
userId: userIdResult.userId,
|
|
347
|
+
assumedUserParams: undefined,
|
|
348
|
+
}, 'getInsecureUser: user not found in database');
|
|
264
349
|
return undefined;
|
|
265
350
|
}
|
|
266
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.
|
|
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",
|
|
@@ -77,6 +77,7 @@ export type BackendAuthClientConfig<
|
|
|
77
77
|
* their user identity. Otherwise, this is `undefined`.
|
|
78
78
|
*/
|
|
79
79
|
assumingUser: AssumedUserParams | undefined;
|
|
80
|
+
requestHeaders: Readonly<IncomingHttpHeaders>;
|
|
80
81
|
}) => MaybePromise<DatabaseUser | undefined | null>;
|
|
81
82
|
/**
|
|
82
83
|
* Get JWT keys produced by {@link generateNewJwtKeys}. Make sure that each time this is
|
|
@@ -90,6 +91,12 @@ export type BackendAuthClientConfig<
|
|
|
90
91
|
*/
|
|
91
92
|
isDev: boolean;
|
|
92
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;
|
|
93
100
|
/**
|
|
94
101
|
* Overwrite the header name used for tracking is an admin is assuming the identity of
|
|
95
102
|
* another user.
|
|
@@ -102,6 +109,8 @@ export type BackendAuthClientConfig<
|
|
|
102
109
|
generateServiceOrigin(params: {
|
|
103
110
|
requestHeaders: Readonly<IncomingHttpHeaders>;
|
|
104
111
|
}): MaybePromise<undefined | string>;
|
|
112
|
+
/** If provided, logs will be sent to this method. */
|
|
113
|
+
log?: (message: string, extraData: AnyObject) => void;
|
|
105
114
|
/**
|
|
106
115
|
* Set this to allow specific users (determined by `canAssumeUser`) to assume the identity
|
|
107
116
|
* of other users. This should only be used for admins so that they can troubleshoot user
|
|
@@ -196,6 +205,30 @@ export class BackendAuthClient<
|
|
|
196
205
|
protected readonly config: BackendAuthClientConfig<DatabaseUser, UserId, AssumedUserParams>,
|
|
197
206
|
) {}
|
|
198
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
|
+
|
|
199
232
|
/** Get all the parameters used for cookie generation. */
|
|
200
233
|
protected async getCookieParams({
|
|
201
234
|
isSignUpCookie,
|
|
@@ -211,7 +244,9 @@ export class BackendAuthClient<
|
|
|
211
244
|
requestHeaders: Readonly<IncomingHttpHeaders> | undefined;
|
|
212
245
|
}): Promise<Readonly<CookieParams>> {
|
|
213
246
|
const serviceOrigin = requestHeaders
|
|
214
|
-
? await this.config.generateServiceOrigin?.({
|
|
247
|
+
? await this.config.generateServiceOrigin?.({
|
|
248
|
+
requestHeaders,
|
|
249
|
+
})
|
|
215
250
|
: undefined;
|
|
216
251
|
|
|
217
252
|
return {
|
|
@@ -228,10 +263,12 @@ export class BackendAuthClient<
|
|
|
228
263
|
isSignUpCookie,
|
|
229
264
|
userId,
|
|
230
265
|
assumingUser,
|
|
266
|
+
requestHeaders,
|
|
231
267
|
}: {
|
|
232
268
|
userId: UserId | undefined;
|
|
233
269
|
assumingUser: AssumedUserParams | undefined;
|
|
234
270
|
isSignUpCookie: boolean;
|
|
271
|
+
requestHeaders: IncomingHttpHeaders;
|
|
235
272
|
}): Promise<undefined | DatabaseUser> {
|
|
236
273
|
if (!userId) {
|
|
237
274
|
return undefined;
|
|
@@ -241,9 +278,21 @@ export class BackendAuthClient<
|
|
|
241
278
|
assumingUser,
|
|
242
279
|
userId,
|
|
243
280
|
isSignUpCookie,
|
|
281
|
+
requestHeaders,
|
|
244
282
|
});
|
|
245
283
|
|
|
246
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
|
+
);
|
|
247
296
|
return undefined;
|
|
248
297
|
}
|
|
249
298
|
|
|
@@ -269,6 +318,18 @@ export class BackendAuthClient<
|
|
|
269
318
|
});
|
|
270
319
|
|
|
271
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
|
+
);
|
|
272
333
|
return undefined;
|
|
273
334
|
}
|
|
274
335
|
|
|
@@ -286,6 +347,19 @@ export class BackendAuthClient<
|
|
|
286
347
|
});
|
|
287
348
|
|
|
288
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
|
+
);
|
|
289
363
|
return undefined;
|
|
290
364
|
}
|
|
291
365
|
}
|
|
@@ -323,24 +397,36 @@ export class BackendAuthClient<
|
|
|
323
397
|
}),
|
|
324
398
|
};
|
|
325
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
|
+
);
|
|
326
412
|
return undefined;
|
|
327
413
|
}
|
|
328
414
|
}
|
|
329
415
|
|
|
330
416
|
/** Reads the user's assumed user headers and, if configured, gets the assumed user. */
|
|
331
417
|
protected async getAssumedUser({
|
|
332
|
-
|
|
418
|
+
requestHeaders,
|
|
333
419
|
user,
|
|
334
420
|
}: {
|
|
335
421
|
user: DatabaseUser;
|
|
336
|
-
|
|
422
|
+
requestHeaders: IncomingHttpHeaders;
|
|
337
423
|
}): Promise<DatabaseUser | undefined> {
|
|
338
424
|
if (!this.config.assumeUser || !(await this.config.assumeUser.canAssumeUser(user))) {
|
|
339
425
|
return undefined;
|
|
340
426
|
}
|
|
341
427
|
|
|
342
428
|
const assumedUserHeader: string | undefined = ensureArray(
|
|
343
|
-
|
|
429
|
+
requestHeaders[this.config.assumedUserHeaderName || AuthHeaderName.AssumedUser],
|
|
344
430
|
)[0];
|
|
345
431
|
|
|
346
432
|
if (!assumedUserHeader) {
|
|
@@ -358,6 +444,7 @@ export class BackendAuthClient<
|
|
|
358
444
|
isSignUpCookie: false,
|
|
359
445
|
userId: parsedAssumedUserData.userId,
|
|
360
446
|
assumingUser: parsedAssumedUserData.assumedUserParams,
|
|
447
|
+
requestHeaders,
|
|
361
448
|
});
|
|
362
449
|
|
|
363
450
|
return assumedUser;
|
|
@@ -385,6 +472,17 @@ export class BackendAuthClient<
|
|
|
385
472
|
isSignUpCookie ? AuthCookieName.SignUp : AuthCookieName.Auth,
|
|
386
473
|
);
|
|
387
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
|
+
);
|
|
388
486
|
return undefined;
|
|
389
487
|
}
|
|
390
488
|
|
|
@@ -392,14 +490,26 @@ export class BackendAuthClient<
|
|
|
392
490
|
userId: userIdResult.userId,
|
|
393
491
|
assumingUser: undefined,
|
|
394
492
|
isSignUpCookie,
|
|
493
|
+
requestHeaders,
|
|
395
494
|
});
|
|
396
495
|
|
|
397
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
|
+
);
|
|
398
508
|
return undefined;
|
|
399
509
|
}
|
|
400
510
|
|
|
401
511
|
const assumedUser = await this.getAssumedUser({
|
|
402
|
-
|
|
512
|
+
requestHeaders,
|
|
403
513
|
user,
|
|
404
514
|
});
|
|
405
515
|
|
|
@@ -429,7 +539,9 @@ export class BackendAuthClient<
|
|
|
429
539
|
const parsedKeys = cachedParsedKeys || (await parseJwtKeys(rawJwtKeys));
|
|
430
540
|
|
|
431
541
|
if (!cachedParsedKeys) {
|
|
432
|
-
this.cachedParsedJwtKeys = {
|
|
542
|
+
this.cachedParsedJwtKeys = {
|
|
543
|
+
[cacheKey]: parsedKeys,
|
|
544
|
+
};
|
|
433
545
|
}
|
|
434
546
|
return {
|
|
435
547
|
jwtKeys: parsedKeys,
|
|
@@ -578,13 +690,19 @@ export class BackendAuthClient<
|
|
|
578
690
|
const secureUser = await this.getSecureUser(params);
|
|
579
691
|
|
|
580
692
|
if (secureUser) {
|
|
581
|
-
return {
|
|
693
|
+
return {
|
|
694
|
+
secureUser,
|
|
695
|
+
};
|
|
582
696
|
}
|
|
583
697
|
|
|
584
698
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
585
699
|
const insecureUser = await this.getInsecureUser(params);
|
|
586
700
|
|
|
587
|
-
return insecureUser
|
|
701
|
+
return insecureUser
|
|
702
|
+
? {
|
|
703
|
+
insecureUser,
|
|
704
|
+
}
|
|
705
|
+
: {};
|
|
588
706
|
}
|
|
589
707
|
|
|
590
708
|
/**
|
|
@@ -612,6 +730,14 @@ export class BackendAuthClient<
|
|
|
612
730
|
);
|
|
613
731
|
|
|
614
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
|
+
);
|
|
615
741
|
return undefined;
|
|
616
742
|
}
|
|
617
743
|
|
|
@@ -619,9 +745,18 @@ export class BackendAuthClient<
|
|
|
619
745
|
isSignUpCookie: false,
|
|
620
746
|
userId: userIdResult.userId,
|
|
621
747
|
assumingUser: undefined,
|
|
748
|
+
requestHeaders,
|
|
622
749
|
});
|
|
623
750
|
|
|
624
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
|
+
);
|
|
625
760
|
return undefined;
|
|
626
761
|
}
|
|
627
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
|
/**
|