auth-vir 3.0.1 → 3.1.1
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 +7 -8
- package/dist/auth-client/backend-auth.client.d.ts +23 -0
- package/dist/auth-client/backend-auth.client.js +110 -17
- package/dist/auth-client/frontend-auth.client.d.ts +4 -2
- package/dist/auth-client/frontend-auth.client.js +14 -22
- package/dist/auth.d.ts +8 -7
- package/dist/auth.js +6 -10
- package/dist/cookie.js +3 -1
- package/dist/csrf-token-store.d.ts +21 -0
- package/dist/csrf-token-store.js +35 -0
- package/dist/csrf-token.d.ts +16 -15
- package/dist/csrf-token.js +13 -10
- package/dist/generated/internal/class.js +2 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/jwt/jwt.js +16 -5
- package/dist/jwt/user-jwt.js +3 -1
- package/dist/mock-csrf-token-store.d.ts +64 -0
- package/dist/mock-csrf-token-store.js +107 -0
- package/package.json +8 -7
- package/src/auth-client/backend-auth.client.ts +169 -26
- package/src/auth-client/frontend-auth.client.ts +15 -25
- package/src/auth.ts +9 -15
- package/src/cookie.ts +3 -1
- package/src/csrf-token-store.ts +54 -0
- package/src/csrf-token.ts +25 -25
- package/src/generated/internal/class.ts +2 -2
- package/src/index.ts +2 -1
- package/src/jwt/jwt.ts +16 -5
- package/src/jwt/user-jwt.ts +3 -1
- package/src/mock-csrf-token-store.ts +141 -0
- package/dist/mock-local-storage.d.ts +0 -33
- package/dist/mock-local-storage.js +0 -56
- package/src/mock-local-storage.ts +0 -72
package/README.md
CHANGED
|
@@ -252,8 +252,7 @@ Use this on your client / frontend for storing and sending session authorization
|
|
|
252
252
|
|
|
253
253
|
1. Send a login fetch request to your host / server / backend with `{credentials: 'include'}` set on the request.
|
|
254
254
|
2. Pass the `Response` from step 1 into [`handleAuthResponse`](https://electrovir.github.io/auth-vir/functions/handleAuthResponse.html).
|
|
255
|
-
3. In all subsequent fetch requests to the host / server / backend, set `{credentials: 'include'}` and include `{headers: {[AuthHeaderName.CsrfToken]: getCurrentCsrfToken()}}`.
|
|
256
|
-
4. Upon user logout, call [`wipeCurrentCsrfToken()`](https://electrovir.github.io/auth-vir/functions/wipeCurrentCsrfToken.html)
|
|
255
|
+
3. In all subsequent fetch requests to the host / server / backend, set `{credentials: 'include'}` and include `{headers: {[AuthHeaderName.CsrfToken]: (await getCurrentCsrfToken()).csrfToken}}`.
|
|
257
256
|
|
|
258
257
|
Here's a full example of how to use all the client / frontend side auth functionality:
|
|
259
258
|
|
|
@@ -282,7 +281,7 @@ export async function sendLoginRequest(
|
|
|
282
281
|
userLoginData: {username: string; password: string},
|
|
283
282
|
loginUrl: string,
|
|
284
283
|
) {
|
|
285
|
-
if (getCurrentCsrfToken(csrfOption).csrfToken) {
|
|
284
|
+
if ((await getCurrentCsrfToken(csrfOption)).csrfToken) {
|
|
286
285
|
throw new Error('Already logged in.');
|
|
287
286
|
}
|
|
288
287
|
|
|
@@ -292,7 +291,7 @@ export async function sendLoginRequest(
|
|
|
292
291
|
credentials: 'include',
|
|
293
292
|
});
|
|
294
293
|
|
|
295
|
-
handleAuthResponse(response, csrfOption);
|
|
294
|
+
await handleAuthResponse(response, csrfOption);
|
|
296
295
|
|
|
297
296
|
return response;
|
|
298
297
|
}
|
|
@@ -303,7 +302,7 @@ export async function sendAuthenticatedRequest(
|
|
|
303
302
|
requestInit: Omit<RequestInit, 'headers'> = {},
|
|
304
303
|
headers: Record<string, string> = {},
|
|
305
304
|
) {
|
|
306
|
-
const {csrfToken} = getCurrentCsrfToken(csrfOption);
|
|
305
|
+
const {csrfToken} = await getCurrentCsrfToken(csrfOption);
|
|
307
306
|
|
|
308
307
|
if (!csrfToken) {
|
|
309
308
|
throw new Error('Not authenticated.');
|
|
@@ -324,7 +323,7 @@ export async function sendAuthenticatedRequest(
|
|
|
324
323
|
* another tab.)
|
|
325
324
|
*/
|
|
326
325
|
if (response.status === HttpStatus.Unauthorized) {
|
|
327
|
-
wipeCurrentCsrfToken(csrfOption);
|
|
326
|
+
await wipeCurrentCsrfToken(csrfOption);
|
|
328
327
|
throw new Error(`User no longer logged in.`);
|
|
329
328
|
} else {
|
|
330
329
|
return response;
|
|
@@ -332,8 +331,8 @@ export async function sendAuthenticatedRequest(
|
|
|
332
331
|
}
|
|
333
332
|
|
|
334
333
|
/** Call this when the user explicitly clicks a "log out" button. */
|
|
335
|
-
export function logout() {
|
|
336
|
-
wipeCurrentCsrfToken(csrfOption);
|
|
334
|
+
export async function logout() {
|
|
335
|
+
await wipeCurrentCsrfToken(csrfOption);
|
|
337
336
|
}
|
|
338
337
|
```
|
|
339
338
|
|
|
@@ -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
|
/**
|
|
@@ -195,6 +209,15 @@ export declare class BackendAuthClient<DatabaseUser extends AnyObject, UserId ex
|
|
|
195
209
|
}>): Promise<Record<string, string | string[]> & {
|
|
196
210
|
'set-cookie': string[];
|
|
197
211
|
}>;
|
|
212
|
+
/**
|
|
213
|
+
* Refreshes a login session by reissuing the auth cookie with the same CSRF token instead of
|
|
214
|
+
* generating a new one.
|
|
215
|
+
*/
|
|
216
|
+
protected refreshLoginHeaders({ userId, cookieParams, existingUserIdResult, }: {
|
|
217
|
+
userId: UserId;
|
|
218
|
+
cookieParams: Readonly<CookieParams>;
|
|
219
|
+
existingUserIdResult: Readonly<UserIdResult<UserId>>;
|
|
220
|
+
}): Promise<Record<string, string>>;
|
|
198
221
|
/** Use these headers to log a user in. */
|
|
199
222
|
createLoginHeaders({ userId, requestHeaders, isSignUpCookie, }: {
|
|
200
223
|
userId: UserId;
|
|
@@ -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,
|
|
@@ -191,24 +256,30 @@ export class BackendAuthClient {
|
|
|
191
256
|
? generateLogoutHeaders(await this.getCookieParams({
|
|
192
257
|
isSignUpCookie: true,
|
|
193
258
|
requestHeaders: undefined,
|
|
194
|
-
})
|
|
259
|
+
}))
|
|
195
260
|
: undefined;
|
|
196
261
|
const authCookieHeaders = params.allCookies || !params.isSignUpCookie
|
|
197
262
|
? generateLogoutHeaders(await this.getCookieParams({
|
|
198
263
|
isSignUpCookie: false,
|
|
199
264
|
requestHeaders: undefined,
|
|
200
|
-
})
|
|
265
|
+
}))
|
|
201
266
|
: undefined;
|
|
202
|
-
|
|
267
|
+
return {
|
|
203
268
|
'set-cookie': mergeHeaderValues(signUpCookieHeaders?.['set-cookie'], authCookieHeaders?.['set-cookie']),
|
|
204
269
|
};
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Refreshes a login session by reissuing the auth cookie with the same CSRF token instead of
|
|
273
|
+
* generating a new one.
|
|
274
|
+
*/
|
|
275
|
+
async refreshLoginHeaders({ userId, cookieParams, existingUserIdResult, }) {
|
|
276
|
+
const { cookie } = await generateAuthCookie({
|
|
277
|
+
csrfToken: existingUserIdResult.csrfToken,
|
|
278
|
+
userId,
|
|
279
|
+
sessionStartedAt: existingUserIdResult.sessionStartedAt,
|
|
280
|
+
}, cookieParams);
|
|
209
281
|
return {
|
|
210
|
-
|
|
211
|
-
...setCookieHeader,
|
|
282
|
+
'set-cookie': cookie,
|
|
212
283
|
};
|
|
213
284
|
}
|
|
214
285
|
/** Use these headers to log a user in. */
|
|
@@ -219,14 +290,20 @@ export class BackendAuthClient {
|
|
|
219
290
|
? generateLogoutHeaders(await this.getCookieParams({
|
|
220
291
|
isSignUpCookie: !isSignUpCookie,
|
|
221
292
|
requestHeaders,
|
|
222
|
-
})
|
|
293
|
+
}))
|
|
223
294
|
: undefined;
|
|
224
295
|
const existingUserIdResult = await extractUserIdFromRequestHeaders(requestHeaders, await this.getJwtParams(), this.config.csrf, isSignUpCookie ? AuthCookieName.SignUp : AuthCookieName.Auth);
|
|
225
|
-
const
|
|
226
|
-
const newCookieHeaders = await generateSuccessfulLoginHeaders(userId, await this.getCookieParams({
|
|
296
|
+
const cookieParams = await this.getCookieParams({
|
|
227
297
|
isSignUpCookie,
|
|
228
298
|
requestHeaders,
|
|
229
|
-
})
|
|
299
|
+
});
|
|
300
|
+
const newCookieHeaders = existingUserIdResult
|
|
301
|
+
? await this.refreshLoginHeaders({
|
|
302
|
+
userId,
|
|
303
|
+
cookieParams,
|
|
304
|
+
existingUserIdResult,
|
|
305
|
+
})
|
|
306
|
+
: await generateSuccessfulLoginHeaders(userId, cookieParams, this.config.csrf);
|
|
230
307
|
return {
|
|
231
308
|
...newCookieHeaders,
|
|
232
309
|
'set-cookie': mergeHeaderValues(newCookieHeaders['set-cookie'], discardOppositeCookieHeaders?.['set-cookie']),
|
|
@@ -241,11 +318,17 @@ export class BackendAuthClient {
|
|
|
241
318
|
async getInsecureOrSecureUser(params) {
|
|
242
319
|
const secureUser = await this.getSecureUser(params);
|
|
243
320
|
if (secureUser) {
|
|
244
|
-
return {
|
|
321
|
+
return {
|
|
322
|
+
secureUser,
|
|
323
|
+
};
|
|
245
324
|
}
|
|
246
325
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
247
326
|
const insecureUser = await this.getInsecureUser(params);
|
|
248
|
-
return insecureUser
|
|
327
|
+
return insecureUser
|
|
328
|
+
? {
|
|
329
|
+
insecureUser,
|
|
330
|
+
}
|
|
331
|
+
: {};
|
|
249
332
|
}
|
|
250
333
|
/**
|
|
251
334
|
* @deprecated This only half authenticates the user. It should only be used in circumstances
|
|
@@ -256,6 +339,11 @@ export class BackendAuthClient {
|
|
|
256
339
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
257
340
|
const userIdResult = await insecureExtractUserIdFromCookieAlone(requestHeaders, await this.getJwtParams(), AuthCookieName.Auth);
|
|
258
341
|
if (!userIdResult) {
|
|
342
|
+
this.logForUser({
|
|
343
|
+
user: undefined,
|
|
344
|
+
userId: undefined,
|
|
345
|
+
assumedUserParams: undefined,
|
|
346
|
+
}, 'getInsecureUser: failed to extract user ID from cookie (invalid JWT or missing cookie)');
|
|
259
347
|
return undefined;
|
|
260
348
|
}
|
|
261
349
|
const user = await this.getDatabaseUser({
|
|
@@ -265,6 +353,11 @@ export class BackendAuthClient {
|
|
|
265
353
|
requestHeaders,
|
|
266
354
|
});
|
|
267
355
|
if (!user) {
|
|
356
|
+
this.logForUser({
|
|
357
|
+
user: undefined,
|
|
358
|
+
userId: userIdResult.userId,
|
|
359
|
+
assumedUserParams: undefined,
|
|
360
|
+
}, 'getInsecureUser: user not found in database');
|
|
268
361
|
return undefined;
|
|
269
362
|
}
|
|
270
363
|
const refreshHeaders = allowUserAuthRefresh &&
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type createBlockingInterval, type JsonCompatibleObject, type MaybePromise, type PartialWithUndefined, type SelectFrom } from '@augment-vir/common';
|
|
2
2
|
import { type AnyDuration } from 'date-vir';
|
|
3
3
|
import { type EmptyObject } from 'type-fest';
|
|
4
|
+
import { type CsrfTokenStore } from '../csrf-token-store.js';
|
|
4
5
|
import { type CsrfHeaderNameOption } from '../csrf-token.js';
|
|
5
6
|
/**
|
|
6
7
|
* Config for {@link FrontendAuthClient}.
|
|
@@ -57,6 +58,7 @@ export type FrontendAuthClientConfig = Readonly<{
|
|
|
57
58
|
allowedClockSkew: Readonly<AnyDuration>;
|
|
58
59
|
overrides: PartialWithUndefined<{
|
|
59
60
|
localStorage: Pick<Storage, 'setItem' | 'removeItem' | 'getItem'>;
|
|
61
|
+
csrfTokenStore: CsrfTokenStore;
|
|
60
62
|
}>;
|
|
61
63
|
}>;
|
|
62
64
|
/**
|
|
@@ -78,7 +80,7 @@ export declare class FrontendAuthClient<AssumedUserParams extends JsonCompatible
|
|
|
78
80
|
*/
|
|
79
81
|
destroy(): void;
|
|
80
82
|
/** Wraps {@link getCurrentCsrfToken} to automatically handle wiping an invalid CSRF token. */
|
|
81
|
-
getCurrentCsrfToken(): string | undefined
|
|
83
|
+
getCurrentCsrfToken(): Promise<string | undefined>;
|
|
82
84
|
/**
|
|
83
85
|
* Assume the given user. Pass `undefined` to wipe the currently assumed user.
|
|
84
86
|
*
|
|
@@ -93,7 +95,7 @@ export declare class FrontendAuthClient<AssumedUserParams extends JsonCompatible
|
|
|
93
95
|
* `@augment-vir/common`](https://electrovir.github.io/augment-vir/functions/mergeDeep.html) to
|
|
94
96
|
* combine them with these.
|
|
95
97
|
*/
|
|
96
|
-
createAuthenticatedRequestInit(): RequestInit
|
|
98
|
+
createAuthenticatedRequestInit(): Promise<RequestInit>;
|
|
97
99
|
/** Wipes the current user auth. */
|
|
98
100
|
logout(): Promise<void>;
|
|
99
101
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { HttpStatus, } from '@augment-vir/common';
|
|
2
2
|
import { listenToActivity } from 'detect-activity';
|
|
3
|
-
import {
|
|
3
|
+
import { defaultAllowedClockSkew, extractCsrfTokenHeader, getCurrentCsrfToken, resolveCsrfHeaderName, storeCsrfToken, } from '../csrf-token.js';
|
|
4
4
|
import { AuthHeaderName } from '../headers.js';
|
|
5
5
|
/**
|
|
6
6
|
* An auth client for sending and validating client requests to a backend. This should only be used
|
|
@@ -26,7 +26,9 @@ export class FrontendAuthClient {
|
|
|
26
26
|
});
|
|
27
27
|
}
|
|
28
28
|
},
|
|
29
|
-
debounce: config.checkUser.debounce || {
|
|
29
|
+
debounce: config.checkUser.debounce || {
|
|
30
|
+
minutes: 1,
|
|
31
|
+
},
|
|
30
32
|
fireImmediately: false,
|
|
31
33
|
});
|
|
32
34
|
}
|
|
@@ -40,19 +42,13 @@ export class FrontendAuthClient {
|
|
|
40
42
|
this.removeActivityListener?.();
|
|
41
43
|
}
|
|
42
44
|
/** Wraps {@link getCurrentCsrfToken} to automatically handle wiping an invalid CSRF token. */
|
|
43
|
-
getCurrentCsrfToken() {
|
|
44
|
-
const csrfTokenResult = getCurrentCsrfToken({
|
|
45
|
+
async getCurrentCsrfToken() {
|
|
46
|
+
const csrfTokenResult = await getCurrentCsrfToken({
|
|
45
47
|
...this.config.csrf,
|
|
46
|
-
|
|
48
|
+
csrfTokenStore: this.config.overrides?.csrfTokenStore,
|
|
47
49
|
allowedClockSkew: this.config.allowedClockSkew || defaultAllowedClockSkew,
|
|
48
50
|
});
|
|
49
51
|
if (csrfTokenResult.failure) {
|
|
50
|
-
if (csrfTokenResult.failure !== CsrfTokenFailureReason.DoesNotExist) {
|
|
51
|
-
wipeCurrentCsrfToken({
|
|
52
|
-
...this.config.csrf,
|
|
53
|
-
localStorage: this.config.overrides?.localStorage,
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
52
|
return undefined;
|
|
57
53
|
}
|
|
58
54
|
return csrfTokenResult.csrfToken.token;
|
|
@@ -69,7 +65,7 @@ export class FrontendAuthClient {
|
|
|
69
65
|
localStorage.removeItem(storageKey);
|
|
70
66
|
return true;
|
|
71
67
|
}
|
|
72
|
-
if (!(await this.config.canAssumeUser?.())) {
|
|
68
|
+
else if (!(await this.config.canAssumeUser?.())) {
|
|
73
69
|
return false;
|
|
74
70
|
}
|
|
75
71
|
localStorage.setItem(storageKey, JSON.stringify(assumedUserParams));
|
|
@@ -94,8 +90,8 @@ export class FrontendAuthClient {
|
|
|
94
90
|
* `@augment-vir/common`](https://electrovir.github.io/augment-vir/functions/mergeDeep.html) to
|
|
95
91
|
* combine them with these.
|
|
96
92
|
*/
|
|
97
|
-
createAuthenticatedRequestInit() {
|
|
98
|
-
const csrfToken = this.getCurrentCsrfToken();
|
|
93
|
+
async createAuthenticatedRequestInit() {
|
|
94
|
+
const csrfToken = await this.getCurrentCsrfToken();
|
|
99
95
|
const assumedUser = this.getAssumedUser();
|
|
100
96
|
const headers = {
|
|
101
97
|
...(csrfToken
|
|
@@ -117,10 +113,6 @@ export class FrontendAuthClient {
|
|
|
117
113
|
/** Wipes the current user auth. */
|
|
118
114
|
async logout() {
|
|
119
115
|
await this.config.authClearedCallback?.();
|
|
120
|
-
wipeCurrentCsrfToken({
|
|
121
|
-
...this.config.csrf,
|
|
122
|
-
localStorage: this.config.overrides?.localStorage,
|
|
123
|
-
});
|
|
124
116
|
}
|
|
125
117
|
/**
|
|
126
118
|
* Use to handle a login response. Automatically stores the CSRF token.
|
|
@@ -140,9 +132,9 @@ export class FrontendAuthClient {
|
|
|
140
132
|
await this.logout();
|
|
141
133
|
throw new Error('Did not receive any CSRF token.');
|
|
142
134
|
}
|
|
143
|
-
storeCsrfToken(csrfToken, {
|
|
135
|
+
await storeCsrfToken(csrfToken, {
|
|
144
136
|
...this.config.csrf,
|
|
145
|
-
|
|
137
|
+
csrfTokenStore: this.config.overrides?.csrfTokenStore,
|
|
146
138
|
});
|
|
147
139
|
}
|
|
148
140
|
/**
|
|
@@ -162,9 +154,9 @@ export class FrontendAuthClient {
|
|
|
162
154
|
allowedClockSkew: this.config.allowedClockSkew || defaultAllowedClockSkew,
|
|
163
155
|
});
|
|
164
156
|
if (csrfToken) {
|
|
165
|
-
storeCsrfToken(csrfToken, {
|
|
157
|
+
await storeCsrfToken(csrfToken, {
|
|
166
158
|
...this.config.csrf,
|
|
167
|
-
|
|
159
|
+
csrfTokenStore: this.config.overrides?.csrfTokenStore,
|
|
168
160
|
});
|
|
169
161
|
}
|
|
170
162
|
return true;
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type PartialWithUndefined } from '@augment-vir/common';
|
|
2
2
|
import { type FullDate, type UtcTimezone } from 'date-vir';
|
|
3
3
|
import { type CookieParams } from './cookie.js';
|
|
4
|
+
import { type CsrfTokenStore } from './csrf-token-store.js';
|
|
4
5
|
import { type CsrfHeaderNameOption } from './csrf-token.js';
|
|
5
6
|
import { type ParseJwtParams } from './jwt/jwt.js';
|
|
6
7
|
import { type JwtUserData } from './jwt/user-jwt.js';
|
|
@@ -66,11 +67,11 @@ sessionStartedAt?: number | undefined): Promise<Record<string, string>>;
|
|
|
66
67
|
*
|
|
67
68
|
* @category Auth : Host
|
|
68
69
|
*/
|
|
69
|
-
export declare function generateLogoutHeaders(cookieConfig: Readonly<Pick<CookieParams, 'cookieName' | 'hostOrigin' | 'isDev'
|
|
70
|
+
export declare function generateLogoutHeaders(cookieConfig: Readonly<Pick<CookieParams, 'cookieName' | 'hostOrigin' | 'isDev'>>): Record<string, string>;
|
|
70
71
|
/**
|
|
71
72
|
* Store auth data on a client (frontend) after receiving an auth response from the host (backend).
|
|
72
|
-
* Specifically, this stores the CSRF token into
|
|
73
|
-
* Alternatively, if the given response failed, this will wipe the existing (if
|
|
73
|
+
* Specifically, this stores the CSRF token into IndexedDB (which doesn't need to be a secret).
|
|
74
|
+
* Alternatively, if the given response failed, this will wipe the existing (if any) stored CSRF
|
|
74
75
|
* token.
|
|
75
76
|
*
|
|
76
77
|
* @category Auth : Client
|
|
@@ -78,9 +79,9 @@ export declare function generateLogoutHeaders(cookieConfig: Readonly<Pick<Cookie
|
|
|
78
79
|
*/
|
|
79
80
|
export declare function handleAuthResponse(response: Readonly<Pick<Response, 'ok' | 'headers'>>, options: Readonly<CsrfHeaderNameOption> & PartialWithUndefined<{
|
|
80
81
|
/**
|
|
81
|
-
* Allows mocking or overriding the
|
|
82
|
+
* Allows mocking or overriding the default CSRF token store.
|
|
82
83
|
*
|
|
83
|
-
* @default
|
|
84
|
+
* @default getDefaultCsrfTokenStore()
|
|
84
85
|
*/
|
|
85
|
-
|
|
86
|
-
}>): void
|
|
86
|
+
csrfTokenStore: CsrfTokenStore;
|
|
87
|
+
}>): Promise<void>;
|
package/dist/auth.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AuthCookieName, clearAuthCookie, extractCookieJwt, generateAuthCookie, } from './cookie.js';
|
|
2
|
-
import { extractCsrfTokenHeader, generateCsrfToken, parseCsrfToken, resolveCsrfHeaderName, storeCsrfToken,
|
|
2
|
+
import { extractCsrfTokenHeader, generateCsrfToken, parseCsrfToken, resolveCsrfHeaderName, storeCsrfToken, } from './csrf-token.js';
|
|
3
3
|
function readHeader(headers, headerName) {
|
|
4
4
|
if (headers instanceof Headers) {
|
|
5
5
|
return headers.get(headerName) || undefined;
|
|
@@ -122,31 +122,27 @@ sessionStartedAt) {
|
|
|
122
122
|
*
|
|
123
123
|
* @category Auth : Host
|
|
124
124
|
*/
|
|
125
|
-
export function generateLogoutHeaders(cookieConfig
|
|
126
|
-
const csrfHeaderName = resolveCsrfHeaderName(csrfHeaderNameOption);
|
|
125
|
+
export function generateLogoutHeaders(cookieConfig) {
|
|
127
126
|
return {
|
|
128
127
|
'set-cookie': clearAuthCookie(cookieConfig),
|
|
129
|
-
[csrfHeaderName]: 'redacted',
|
|
130
128
|
};
|
|
131
129
|
}
|
|
132
130
|
/**
|
|
133
131
|
* Store auth data on a client (frontend) after receiving an auth response from the host (backend).
|
|
134
|
-
* Specifically, this stores the CSRF token into
|
|
135
|
-
* Alternatively, if the given response failed, this will wipe the existing (if
|
|
132
|
+
* Specifically, this stores the CSRF token into IndexedDB (which doesn't need to be a secret).
|
|
133
|
+
* Alternatively, if the given response failed, this will wipe the existing (if any) stored CSRF
|
|
136
134
|
* token.
|
|
137
135
|
*
|
|
138
136
|
* @category Auth : Client
|
|
139
137
|
* @throws Error if no CSRF token header is found.
|
|
140
138
|
*/
|
|
141
|
-
export function handleAuthResponse(response, options) {
|
|
139
|
+
export async function handleAuthResponse(response, options) {
|
|
142
140
|
if (!response.ok) {
|
|
143
|
-
wipeCurrentCsrfToken(options);
|
|
144
141
|
return;
|
|
145
142
|
}
|
|
146
143
|
const { csrfToken } = extractCsrfTokenHeader(response, options);
|
|
147
144
|
if (!csrfToken) {
|
|
148
|
-
wipeCurrentCsrfToken(options);
|
|
149
145
|
throw new Error('Did not receive any CSRF token.');
|
|
150
146
|
}
|
|
151
|
-
storeCsrfToken(csrfToken, options);
|
|
147
|
+
await storeCsrfToken(csrfToken, options);
|
|
152
148
|
}
|
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,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interface used for overriding the default CSRF token store in storage functions.
|
|
3
|
+
*
|
|
4
|
+
* @category Internal
|
|
5
|
+
*/
|
|
6
|
+
export type CsrfTokenStore = {
|
|
7
|
+
/** Retrieves the stored CSRF token, if any. */
|
|
8
|
+
getCsrfToken(): Promise<string | undefined>;
|
|
9
|
+
/** Stores a CSRF token. */
|
|
10
|
+
setCsrfToken(value: string): Promise<void>;
|
|
11
|
+
/** Deletes the stored CSRF token. */
|
|
12
|
+
deleteCsrfToken(): Promise<void>;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* The default {@link LocalDbClient} instance used for storing CSRF tokens. This uses a dedicated
|
|
16
|
+
* store name to avoid collisions with other storage. Lazily initialized to avoid crashes in Node.js
|
|
17
|
+
* environments where IndexedDB is not available.
|
|
18
|
+
*
|
|
19
|
+
* @category Internal
|
|
20
|
+
*/
|
|
21
|
+
export declare function getDefaultCsrfTokenStore(): Promise<CsrfTokenStore>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { LocalDbClient } from 'local-db-client';
|
|
2
|
+
import { defineShape } from 'object-shape-tester';
|
|
3
|
+
const csrfTokenDbShapes = {
|
|
4
|
+
csrfToken: defineShape(''),
|
|
5
|
+
};
|
|
6
|
+
async function createDefaultCsrfTokenStore() {
|
|
7
|
+
const client = await LocalDbClient.createClient(csrfTokenDbShapes, {
|
|
8
|
+
storeName: 'auth-vir-csrf',
|
|
9
|
+
});
|
|
10
|
+
return {
|
|
11
|
+
async getCsrfToken() {
|
|
12
|
+
return (await client.load.csrfToken()) || undefined;
|
|
13
|
+
},
|
|
14
|
+
async setCsrfToken(value) {
|
|
15
|
+
await client.set.csrfToken(value);
|
|
16
|
+
},
|
|
17
|
+
async deleteCsrfToken() {
|
|
18
|
+
await client.delete.csrfToken();
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The default {@link LocalDbClient} instance used for storing CSRF tokens. This uses a dedicated
|
|
24
|
+
* store name to avoid collisions with other storage. Lazily initialized to avoid crashes in Node.js
|
|
25
|
+
* environments where IndexedDB is not available.
|
|
26
|
+
*
|
|
27
|
+
* @category Internal
|
|
28
|
+
*/
|
|
29
|
+
export async function getDefaultCsrfTokenStore() {
|
|
30
|
+
if (!cachedStorePromise) {
|
|
31
|
+
cachedStorePromise = createDefaultCsrfTokenStore();
|
|
32
|
+
}
|
|
33
|
+
return cachedStorePromise;
|
|
34
|
+
}
|
|
35
|
+
let cachedStorePromise;
|