auth-vir 3.1.0 → 3.1.2
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 +9 -0
- package/dist/auth-client/backend-auth.client.js +25 -13
- package/dist/auth-client/frontend-auth.client.d.ts +4 -2
- package/dist/auth-client/frontend-auth.client.js +9 -29
- package/dist/auth.d.ts +8 -7
- package/dist/auth.js +6 -10
- 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 +10 -9
- package/dist/generated/internal/class.js +2 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/mock-csrf-token-store.d.ts +64 -0
- package/dist/{mock-local-storage.js → mock-csrf-token-store.js} +48 -0
- package/package.json +7 -6
- package/src/auth-client/backend-auth.client.ts +37 -22
- package/src/auth-client/frontend-auth.client.ts +10 -33
- package/src/auth.ts +9 -15
- package/src/csrf-token-store.ts +54 -0
- package/src/csrf-token.ts +22 -24
- package/src/generated/internal/class.ts +2 -2
- package/src/index.ts +2 -1
- package/src/{mock-local-storage.ts → mock-csrf-token-store.ts} +66 -0
- package/dist/mock-local-storage.d.ts +0 -33
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
|
|
|
@@ -209,6 +209,15 @@ export declare class BackendAuthClient<DatabaseUser extends AnyObject, UserId ex
|
|
|
209
209
|
}>): Promise<Record<string, string | string[]> & {
|
|
210
210
|
'set-cookie': string[];
|
|
211
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>>;
|
|
212
221
|
/** Use these headers to log a user in. */
|
|
213
222
|
createLoginHeaders({ userId, requestHeaders, isSignUpCookie, }: {
|
|
214
223
|
userId: UserId;
|
|
@@ -256,24 +256,30 @@ export class BackendAuthClient {
|
|
|
256
256
|
? generateLogoutHeaders(await this.getCookieParams({
|
|
257
257
|
isSignUpCookie: true,
|
|
258
258
|
requestHeaders: undefined,
|
|
259
|
-
})
|
|
259
|
+
}))
|
|
260
260
|
: undefined;
|
|
261
261
|
const authCookieHeaders = params.allCookies || !params.isSignUpCookie
|
|
262
262
|
? generateLogoutHeaders(await this.getCookieParams({
|
|
263
263
|
isSignUpCookie: false,
|
|
264
264
|
requestHeaders: undefined,
|
|
265
|
-
})
|
|
265
|
+
}))
|
|
266
266
|
: undefined;
|
|
267
|
-
|
|
267
|
+
return {
|
|
268
268
|
'set-cookie': mergeHeaderValues(signUpCookieHeaders?.['set-cookie'], authCookieHeaders?.['set-cookie']),
|
|
269
269
|
};
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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);
|
|
274
281
|
return {
|
|
275
|
-
|
|
276
|
-
...setCookieHeader,
|
|
282
|
+
'set-cookie': cookie,
|
|
277
283
|
};
|
|
278
284
|
}
|
|
279
285
|
/** Use these headers to log a user in. */
|
|
@@ -284,14 +290,20 @@ export class BackendAuthClient {
|
|
|
284
290
|
? generateLogoutHeaders(await this.getCookieParams({
|
|
285
291
|
isSignUpCookie: !isSignUpCookie,
|
|
286
292
|
requestHeaders,
|
|
287
|
-
})
|
|
293
|
+
}))
|
|
288
294
|
: undefined;
|
|
289
295
|
const existingUserIdResult = await extractUserIdFromRequestHeaders(requestHeaders, await this.getJwtParams(), this.config.csrf, isSignUpCookie ? AuthCookieName.SignUp : AuthCookieName.Auth);
|
|
290
|
-
const
|
|
291
|
-
const newCookieHeaders = await generateSuccessfulLoginHeaders(userId, await this.getCookieParams({
|
|
296
|
+
const cookieParams = await this.getCookieParams({
|
|
292
297
|
isSignUpCookie,
|
|
293
298
|
requestHeaders,
|
|
294
|
-
})
|
|
299
|
+
});
|
|
300
|
+
const newCookieHeaders = existingUserIdResult
|
|
301
|
+
? await this.refreshLoginHeaders({
|
|
302
|
+
userId,
|
|
303
|
+
cookieParams,
|
|
304
|
+
existingUserIdResult,
|
|
305
|
+
})
|
|
306
|
+
: await generateSuccessfulLoginHeaders(userId, cookieParams, this.config.csrf);
|
|
295
307
|
return {
|
|
296
308
|
...newCookieHeaders,
|
|
297
309
|
'set-cookie': mergeHeaderValues(newCookieHeaders['set-cookie'], discardOppositeCookieHeaders?.['set-cookie']),
|
|
@@ -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
|
|
@@ -42,19 +42,13 @@ export class FrontendAuthClient {
|
|
|
42
42
|
this.removeActivityListener?.();
|
|
43
43
|
}
|
|
44
44
|
/** Wraps {@link getCurrentCsrfToken} to automatically handle wiping an invalid CSRF token. */
|
|
45
|
-
getCurrentCsrfToken() {
|
|
46
|
-
const csrfTokenResult = getCurrentCsrfToken({
|
|
45
|
+
async getCurrentCsrfToken() {
|
|
46
|
+
const csrfTokenResult = await getCurrentCsrfToken({
|
|
47
47
|
...this.config.csrf,
|
|
48
|
-
|
|
48
|
+
csrfTokenStore: this.config.overrides?.csrfTokenStore,
|
|
49
49
|
allowedClockSkew: this.config.allowedClockSkew || defaultAllowedClockSkew,
|
|
50
50
|
});
|
|
51
51
|
if (csrfTokenResult.failure) {
|
|
52
|
-
if (csrfTokenResult.failure !== CsrfTokenFailureReason.DoesNotExist) {
|
|
53
|
-
wipeCurrentCsrfToken({
|
|
54
|
-
...this.config.csrf,
|
|
55
|
-
localStorage: this.config.overrides?.localStorage,
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
52
|
return undefined;
|
|
59
53
|
}
|
|
60
54
|
return csrfTokenResult.csrfToken.token;
|
|
@@ -71,7 +65,7 @@ export class FrontendAuthClient {
|
|
|
71
65
|
localStorage.removeItem(storageKey);
|
|
72
66
|
return true;
|
|
73
67
|
}
|
|
74
|
-
if (!(await this.config.canAssumeUser?.())) {
|
|
68
|
+
else if (!(await this.config.canAssumeUser?.())) {
|
|
75
69
|
return false;
|
|
76
70
|
}
|
|
77
71
|
localStorage.setItem(storageKey, JSON.stringify(assumedUserParams));
|
|
@@ -96,8 +90,8 @@ export class FrontendAuthClient {
|
|
|
96
90
|
* `@augment-vir/common`](https://electrovir.github.io/augment-vir/functions/mergeDeep.html) to
|
|
97
91
|
* combine them with these.
|
|
98
92
|
*/
|
|
99
|
-
createAuthenticatedRequestInit() {
|
|
100
|
-
const csrfToken = this.getCurrentCsrfToken();
|
|
93
|
+
async createAuthenticatedRequestInit() {
|
|
94
|
+
const csrfToken = await this.getCurrentCsrfToken();
|
|
101
95
|
const assumedUser = this.getAssumedUser();
|
|
102
96
|
const headers = {
|
|
103
97
|
...(csrfToken
|
|
@@ -119,10 +113,6 @@ export class FrontendAuthClient {
|
|
|
119
113
|
/** Wipes the current user auth. */
|
|
120
114
|
async logout() {
|
|
121
115
|
await this.config.authClearedCallback?.();
|
|
122
|
-
wipeCurrentCsrfToken({
|
|
123
|
-
...this.config.csrf,
|
|
124
|
-
localStorage: this.config.overrides?.localStorage,
|
|
125
|
-
});
|
|
126
116
|
}
|
|
127
117
|
/**
|
|
128
118
|
* Use to handle a login response. Automatically stores the CSRF token.
|
|
@@ -142,9 +132,9 @@ export class FrontendAuthClient {
|
|
|
142
132
|
await this.logout();
|
|
143
133
|
throw new Error('Did not receive any CSRF token.');
|
|
144
134
|
}
|
|
145
|
-
storeCsrfToken(csrfToken, {
|
|
135
|
+
await storeCsrfToken(csrfToken, {
|
|
146
136
|
...this.config.csrf,
|
|
147
|
-
|
|
137
|
+
csrfTokenStore: this.config.overrides?.csrfTokenStore,
|
|
148
138
|
});
|
|
149
139
|
}
|
|
150
140
|
/**
|
|
@@ -159,16 +149,6 @@ export class FrontendAuthClient {
|
|
|
159
149
|
await this.logout();
|
|
160
150
|
return false;
|
|
161
151
|
}
|
|
162
|
-
/** If the response has a new CSRF token, store it. */
|
|
163
|
-
const { csrfToken } = extractCsrfTokenHeader(response, this.config.csrf, {
|
|
164
|
-
allowedClockSkew: this.config.allowedClockSkew || defaultAllowedClockSkew,
|
|
165
|
-
});
|
|
166
|
-
if (csrfToken) {
|
|
167
|
-
storeCsrfToken(csrfToken, {
|
|
168
|
-
...this.config.csrf,
|
|
169
|
-
localStorage: this.config.overrides?.localStorage,
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
152
|
return true;
|
|
173
153
|
}
|
|
174
154
|
}
|
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
|
}
|
|
@@ -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;
|
package/dist/csrf-token.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type PartialWithUndefined, type SelectFrom } from '@augment-vir/common';
|
|
2
2
|
import { type AnyDuration } from 'date-vir';
|
|
3
3
|
import { type RequireExactlyOne } from 'type-fest';
|
|
4
|
+
import { type CsrfTokenStore } from './csrf-token-store.js';
|
|
4
5
|
/**
|
|
5
6
|
* Shape definition for {@link CsrfToken}.
|
|
6
7
|
*
|
|
@@ -100,18 +101,18 @@ export declare function extractCsrfTokenHeader(response: Readonly<PartialWithUnd
|
|
|
100
101
|
allowedClockSkew: Readonly<AnyDuration>;
|
|
101
102
|
}>): Readonly<GetCsrfTokenResult>;
|
|
102
103
|
/**
|
|
103
|
-
* Stores the given CSRF token into
|
|
104
|
+
* Stores the given CSRF token into IndexedDB.
|
|
104
105
|
*
|
|
105
106
|
* @category Auth : Client
|
|
106
107
|
*/
|
|
107
108
|
export declare function storeCsrfToken(csrfToken: Readonly<CsrfToken>, options: Readonly<CsrfHeaderNameOption> & PartialWithUndefined<{
|
|
108
109
|
/**
|
|
109
|
-
* Allows mocking or overriding the
|
|
110
|
+
* Allows mocking or overriding the default CSRF token store.
|
|
110
111
|
*
|
|
111
|
-
* @default
|
|
112
|
+
* @default getDefaultCsrfTokenStore()
|
|
112
113
|
*/
|
|
113
|
-
|
|
114
|
-
}>): void
|
|
114
|
+
csrfTokenStore: CsrfTokenStore;
|
|
115
|
+
}>): Promise<void>;
|
|
115
116
|
/**
|
|
116
117
|
* Parse a raw CSRF token JSON string.
|
|
117
118
|
*
|
|
@@ -134,29 +135,29 @@ export declare function parseCsrfToken(value: string | undefined | null, options
|
|
|
134
135
|
*/
|
|
135
136
|
export declare function getCurrentCsrfToken(options: Readonly<CsrfHeaderNameOption> & PartialWithUndefined<{
|
|
136
137
|
/**
|
|
137
|
-
* Allows mocking or overriding the
|
|
138
|
+
* Allows mocking or overriding the default CSRF token store.
|
|
138
139
|
*
|
|
139
|
-
* @default
|
|
140
|
+
* @default getDefaultCsrfTokenStore()
|
|
140
141
|
*/
|
|
141
|
-
|
|
142
|
+
csrfTokenStore: CsrfTokenStore;
|
|
142
143
|
/**
|
|
143
144
|
* Allowed clock skew tolerance for CSRF token expiration checks.
|
|
144
145
|
*
|
|
145
146
|
* @default {minutes: 5}
|
|
146
147
|
*/
|
|
147
148
|
allowedClockSkew: Readonly<AnyDuration>;
|
|
148
|
-
}>): Readonly<GetCsrfTokenResult
|
|
149
|
+
}>): Promise<Readonly<GetCsrfTokenResult>>;
|
|
149
150
|
/**
|
|
150
|
-
* Wipes the current stored CSRF token. This should be used by client (frontend) code to
|
|
151
|
-
*
|
|
151
|
+
* Wipes the current stored CSRF token. This should be used by client (frontend) code to react to a
|
|
152
|
+
* session timeout.
|
|
152
153
|
*
|
|
153
154
|
* @category Auth : Client
|
|
154
155
|
*/
|
|
155
156
|
export declare function wipeCurrentCsrfToken(options: Readonly<CsrfHeaderNameOption> & PartialWithUndefined<{
|
|
156
157
|
/**
|
|
157
|
-
* Allows mocking or overriding the
|
|
158
|
+
* Allows mocking or overriding the default CSRF token store.
|
|
158
159
|
*
|
|
159
|
-
* @default
|
|
160
|
+
* @default getDefaultCsrfTokenStore()
|
|
160
161
|
*/
|
|
161
|
-
|
|
162
|
-
}>): void
|
|
162
|
+
csrfTokenStore: CsrfTokenStore;
|
|
163
|
+
}>): Promise<void>;
|
package/dist/csrf-token.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomString, wrapInTry, } from '@augment-vir/common';
|
|
2
2
|
import { calculateRelativeDate, fullDateShape, getNowInUtcTimezone, isDateAfter, } from 'date-vir';
|
|
3
3
|
import { defineShape, parseJsonWithShape } from 'object-shape-tester';
|
|
4
|
+
import { getDefaultCsrfTokenStore } from './csrf-token-store.js';
|
|
4
5
|
/**
|
|
5
6
|
* Shape definition for {@link CsrfToken}.
|
|
6
7
|
*
|
|
@@ -76,12 +77,12 @@ export function extractCsrfTokenHeader(response, csrfHeaderNameOption, options)
|
|
|
76
77
|
return parseCsrfToken(rawCsrfToken, options);
|
|
77
78
|
}
|
|
78
79
|
/**
|
|
79
|
-
* Stores the given CSRF token into
|
|
80
|
+
* Stores the given CSRF token into IndexedDB.
|
|
80
81
|
*
|
|
81
82
|
* @category Auth : Client
|
|
82
83
|
*/
|
|
83
|
-
export function storeCsrfToken(csrfToken, options) {
|
|
84
|
-
(options.
|
|
84
|
+
export async function storeCsrfToken(csrfToken, options) {
|
|
85
|
+
await (options.csrfTokenStore || (await getDefaultCsrfTokenStore())).setCsrfToken(JSON.stringify(csrfToken));
|
|
85
86
|
}
|
|
86
87
|
/**
|
|
87
88
|
* Parse a raw CSRF token JSON string.
|
|
@@ -124,17 +125,17 @@ export function parseCsrfToken(value, options) {
|
|
|
124
125
|
*
|
|
125
126
|
* @category Auth : Client
|
|
126
127
|
*/
|
|
127
|
-
export function getCurrentCsrfToken(options) {
|
|
128
|
-
const rawCsrfToken = (options.
|
|
128
|
+
export async function getCurrentCsrfToken(options) {
|
|
129
|
+
const rawCsrfToken = (await (options.csrfTokenStore || (await getDefaultCsrfTokenStore())).getCsrfToken()) ||
|
|
129
130
|
undefined;
|
|
130
131
|
return parseCsrfToken(rawCsrfToken, options);
|
|
131
132
|
}
|
|
132
133
|
/**
|
|
133
|
-
* Wipes the current stored CSRF token. This should be used by client (frontend) code to
|
|
134
|
-
*
|
|
134
|
+
* Wipes the current stored CSRF token. This should be used by client (frontend) code to react to a
|
|
135
|
+
* session timeout.
|
|
135
136
|
*
|
|
136
137
|
* @category Auth : Client
|
|
137
138
|
*/
|
|
138
|
-
export function wipeCurrentCsrfToken(options) {
|
|
139
|
-
|
|
139
|
+
export async function wipeCurrentCsrfToken(options) {
|
|
140
|
+
await (options.csrfTokenStore || (await getDefaultCsrfTokenStore())).deleteCsrfToken();
|
|
140
141
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,10 +3,11 @@ export * from './auth-client/frontend-auth.client.js';
|
|
|
3
3
|
export * from './auth-client/is-session-refresh-ready.js';
|
|
4
4
|
export * from './auth.js';
|
|
5
5
|
export * from './cookie.js';
|
|
6
|
+
export * from './csrf-token-store.js';
|
|
6
7
|
export * from './csrf-token.js';
|
|
7
8
|
export * from './hash.js';
|
|
8
9
|
export * from './headers.js';
|
|
9
10
|
export * from './jwt/jwt-keys.js';
|
|
10
11
|
export * from './jwt/jwt.js';
|
|
11
12
|
export * from './jwt/user-jwt.js';
|
|
12
|
-
export * from './mock-
|
|
13
|
+
export * from './mock-csrf-token-store.js';
|
package/dist/index.js
CHANGED
|
@@ -3,10 +3,11 @@ export * from './auth-client/frontend-auth.client.js';
|
|
|
3
3
|
export * from './auth-client/is-session-refresh-ready.js';
|
|
4
4
|
export * from './auth.js';
|
|
5
5
|
export * from './cookie.js';
|
|
6
|
+
export * from './csrf-token-store.js';
|
|
6
7
|
export * from './csrf-token.js';
|
|
7
8
|
export * from './hash.js';
|
|
8
9
|
export * from './headers.js';
|
|
9
10
|
export * from './jwt/jwt-keys.js';
|
|
10
11
|
export * from './jwt/jwt.js';
|
|
11
12
|
export * from './jwt/user-jwt.js';
|
|
12
|
-
export * from './mock-
|
|
13
|
+
export * from './mock-csrf-token-store.js';
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type CsrfTokenStore } from './csrf-token-store.js';
|
|
2
|
+
/**
|
|
3
|
+
* `accessRecord` type for {@link createMockLocalStorage}'s output.
|
|
4
|
+
*
|
|
5
|
+
* @category Internal
|
|
6
|
+
*/
|
|
7
|
+
export type MockLocalStorageAccessRecord = {
|
|
8
|
+
getItem: string[];
|
|
9
|
+
removeItem: string[];
|
|
10
|
+
setItem: {
|
|
11
|
+
key: string;
|
|
12
|
+
value: string;
|
|
13
|
+
}[];
|
|
14
|
+
key: number[];
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Create an empty `accessRecord` object, this is to be used in conjunction with
|
|
18
|
+
* {@link createMockLocalStorage}.
|
|
19
|
+
*
|
|
20
|
+
* @category Mock
|
|
21
|
+
*/
|
|
22
|
+
export declare function createEmptyMockLocalStorageAccessRecord(): MockLocalStorageAccessRecord;
|
|
23
|
+
/**
|
|
24
|
+
* Create a LocalStorage mock.
|
|
25
|
+
*
|
|
26
|
+
* @category Mock
|
|
27
|
+
*/
|
|
28
|
+
export declare function createMockLocalStorage(
|
|
29
|
+
/** Set values in here to initialize the mocked localStorage data store contents. */
|
|
30
|
+
init?: Record<string, string>): {
|
|
31
|
+
localStorage: Storage;
|
|
32
|
+
store: Record<string, string>;
|
|
33
|
+
accessRecord: MockLocalStorageAccessRecord;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* `accessRecord` type for {@link createMockCsrfTokenStore}'s output.
|
|
37
|
+
*
|
|
38
|
+
* @category Internal
|
|
39
|
+
*/
|
|
40
|
+
export type MockCsrfTokenStoreAccessRecord = {
|
|
41
|
+
getCsrfToken: number;
|
|
42
|
+
setCsrfToken: string[];
|
|
43
|
+
deleteCsrfToken: number;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Create an empty `accessRecord` object, this is to be used in conjunction with
|
|
47
|
+
* {@link createMockCsrfTokenStore}.
|
|
48
|
+
*
|
|
49
|
+
* @category Mock
|
|
50
|
+
*/
|
|
51
|
+
export declare function createEmptyMockCsrfTokenStoreAccessRecord(): MockCsrfTokenStoreAccessRecord;
|
|
52
|
+
/**
|
|
53
|
+
* Create a mock {@link CsrfTokenStore} backed by a simple in-memory object, for use in tests.
|
|
54
|
+
*
|
|
55
|
+
* @category Mock
|
|
56
|
+
*/
|
|
57
|
+
export declare function createMockCsrfTokenStore(
|
|
58
|
+
/** Set an initial value to initialize the mocked store contents. */
|
|
59
|
+
init?: string | undefined): {
|
|
60
|
+
csrfTokenStore: CsrfTokenStore;
|
|
61
|
+
/** The current value held in the mock store. */
|
|
62
|
+
readonly storedValue: string | undefined;
|
|
63
|
+
accessRecord: MockCsrfTokenStoreAccessRecord;
|
|
64
|
+
};
|
|
@@ -57,3 +57,51 @@ init = {}) {
|
|
|
57
57
|
accessRecord,
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Create an empty `accessRecord` object, this is to be used in conjunction with
|
|
62
|
+
* {@link createMockCsrfTokenStore}.
|
|
63
|
+
*
|
|
64
|
+
* @category Mock
|
|
65
|
+
*/
|
|
66
|
+
export function createEmptyMockCsrfTokenStoreAccessRecord() {
|
|
67
|
+
return {
|
|
68
|
+
getCsrfToken: 0,
|
|
69
|
+
setCsrfToken: [],
|
|
70
|
+
deleteCsrfToken: 0,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Create a mock {@link CsrfTokenStore} backed by a simple in-memory object, for use in tests.
|
|
75
|
+
*
|
|
76
|
+
* @category Mock
|
|
77
|
+
*/
|
|
78
|
+
export function createMockCsrfTokenStore(
|
|
79
|
+
/** Set an initial value to initialize the mocked store contents. */
|
|
80
|
+
init) {
|
|
81
|
+
let storedValue = init;
|
|
82
|
+
const accessRecord = createEmptyMockCsrfTokenStoreAccessRecord();
|
|
83
|
+
const csrfTokenStore = {
|
|
84
|
+
getCsrfToken() {
|
|
85
|
+
accessRecord.getCsrfToken++;
|
|
86
|
+
return Promise.resolve(storedValue);
|
|
87
|
+
},
|
|
88
|
+
setCsrfToken(value) {
|
|
89
|
+
accessRecord.setCsrfToken.push(value);
|
|
90
|
+
storedValue = value;
|
|
91
|
+
return Promise.resolve();
|
|
92
|
+
},
|
|
93
|
+
deleteCsrfToken() {
|
|
94
|
+
accessRecord.deleteCsrfToken++;
|
|
95
|
+
storedValue = undefined;
|
|
96
|
+
return Promise.resolve();
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
return {
|
|
100
|
+
csrfTokenStore,
|
|
101
|
+
/** The current value held in the mock store. */
|
|
102
|
+
get storedValue() {
|
|
103
|
+
return storedValue;
|
|
104
|
+
},
|
|
105
|
+
accessRecord,
|
|
106
|
+
};
|
|
107
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auth-vir",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.2",
|
|
4
4
|
"description": "Auth made easy and secure via JWT cookies, CSRF tokens, and password hashing helpers.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"auth",
|
|
@@ -41,18 +41,19 @@
|
|
|
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.2.
|
|
44
|
+
"@augment-vir/assert": "^31.68.1",
|
|
45
|
+
"@augment-vir/common": "^31.68.1",
|
|
46
|
+
"date-vir": "^8.2.1",
|
|
47
47
|
"detect-activity": "^1.0.0",
|
|
48
48
|
"hash-wasm": "^4.12.0",
|
|
49
|
-
"jose": "^6.1
|
|
49
|
+
"jose": "^6.2.1",
|
|
50
|
+
"local-db-client": "^1.0.0",
|
|
50
51
|
"object-shape-tester": "^6.11.0",
|
|
51
52
|
"type-fest": "^5.4.4",
|
|
52
53
|
"url-vir": "^2.1.7"
|
|
53
54
|
},
|
|
54
55
|
"devDependencies": {
|
|
55
|
-
"@augment-vir/test": "^31.
|
|
56
|
+
"@augment-vir/test": "^31.68.1",
|
|
56
57
|
"@prisma/client": "^6.19.2",
|
|
57
58
|
"@types/node": "^25.3.3",
|
|
58
59
|
"@web/dev-server-esbuild": "^1.0.5",
|
|
@@ -575,7 +575,6 @@ export class BackendAuthClient<
|
|
|
575
575
|
isSignUpCookie: true,
|
|
576
576
|
requestHeaders: undefined,
|
|
577
577
|
}),
|
|
578
|
-
this.config.csrf,
|
|
579
578
|
)
|
|
580
579
|
: undefined;
|
|
581
580
|
const authCookieHeaders =
|
|
@@ -585,26 +584,41 @@ export class BackendAuthClient<
|
|
|
585
584
|
isSignUpCookie: false,
|
|
586
585
|
requestHeaders: undefined,
|
|
587
586
|
}),
|
|
588
|
-
this.config.csrf,
|
|
589
587
|
)
|
|
590
588
|
: undefined;
|
|
591
589
|
|
|
592
|
-
|
|
593
|
-
'set-cookie': string[];
|
|
594
|
-
} = {
|
|
590
|
+
return {
|
|
595
591
|
'set-cookie': mergeHeaderValues(
|
|
596
592
|
signUpCookieHeaders?.['set-cookie'],
|
|
597
593
|
authCookieHeaders?.['set-cookie'],
|
|
598
594
|
),
|
|
599
595
|
};
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Refreshes a login session by reissuing the auth cookie with the same CSRF token instead of
|
|
600
|
+
* generating a new one.
|
|
601
|
+
*/
|
|
602
|
+
protected async refreshLoginHeaders({
|
|
603
|
+
userId,
|
|
604
|
+
cookieParams,
|
|
605
|
+
existingUserIdResult,
|
|
606
|
+
}: {
|
|
607
|
+
userId: UserId;
|
|
608
|
+
cookieParams: Readonly<CookieParams>;
|
|
609
|
+
existingUserIdResult: Readonly<UserIdResult<UserId>>;
|
|
610
|
+
}): Promise<Record<string, string>> {
|
|
611
|
+
const {cookie} = await generateAuthCookie(
|
|
612
|
+
{
|
|
613
|
+
csrfToken: existingUserIdResult.csrfToken,
|
|
614
|
+
userId,
|
|
615
|
+
sessionStartedAt: existingUserIdResult.sessionStartedAt,
|
|
616
|
+
},
|
|
617
|
+
cookieParams,
|
|
618
|
+
);
|
|
604
619
|
|
|
605
620
|
return {
|
|
606
|
-
|
|
607
|
-
...setCookieHeader,
|
|
621
|
+
'set-cookie': cookie,
|
|
608
622
|
};
|
|
609
623
|
}
|
|
610
624
|
|
|
@@ -627,7 +641,6 @@ export class BackendAuthClient<
|
|
|
627
641
|
isSignUpCookie: !isSignUpCookie,
|
|
628
642
|
requestHeaders,
|
|
629
643
|
}),
|
|
630
|
-
this.config.csrf,
|
|
631
644
|
)
|
|
632
645
|
: undefined;
|
|
633
646
|
|
|
@@ -637,17 +650,19 @@ export class BackendAuthClient<
|
|
|
637
650
|
this.config.csrf,
|
|
638
651
|
isSignUpCookie ? AuthCookieName.SignUp : AuthCookieName.Auth,
|
|
639
652
|
);
|
|
640
|
-
const sessionStartedAt = existingUserIdResult?.sessionStartedAt;
|
|
641
653
|
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
this.
|
|
649
|
-
|
|
650
|
-
|
|
654
|
+
const cookieParams = await this.getCookieParams({
|
|
655
|
+
isSignUpCookie,
|
|
656
|
+
requestHeaders,
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
const newCookieHeaders = existingUserIdResult
|
|
660
|
+
? await this.refreshLoginHeaders({
|
|
661
|
+
userId,
|
|
662
|
+
cookieParams,
|
|
663
|
+
existingUserIdResult,
|
|
664
|
+
})
|
|
665
|
+
: await generateSuccessfulLoginHeaders(userId, cookieParams, this.config.csrf);
|
|
651
666
|
|
|
652
667
|
return {
|
|
653
668
|
...newCookieHeaders,
|
|
@@ -9,15 +9,14 @@ import {
|
|
|
9
9
|
import {type AnyDuration} from 'date-vir';
|
|
10
10
|
import {listenToActivity} from 'detect-activity';
|
|
11
11
|
import {type EmptyObject} from 'type-fest';
|
|
12
|
+
import {type CsrfTokenStore} from '../csrf-token-store.js';
|
|
12
13
|
import {
|
|
13
14
|
type CsrfHeaderNameOption,
|
|
14
|
-
CsrfTokenFailureReason,
|
|
15
15
|
defaultAllowedClockSkew,
|
|
16
16
|
extractCsrfTokenHeader,
|
|
17
17
|
getCurrentCsrfToken,
|
|
18
18
|
resolveCsrfHeaderName,
|
|
19
19
|
storeCsrfToken,
|
|
20
|
-
wipeCurrentCsrfToken,
|
|
21
20
|
} from '../csrf-token.js';
|
|
22
21
|
import {AuthHeaderName} from '../headers.js';
|
|
23
22
|
|
|
@@ -85,6 +84,7 @@ export type FrontendAuthClientConfig = Readonly<{
|
|
|
85
84
|
|
|
86
85
|
overrides: PartialWithUndefined<{
|
|
87
86
|
localStorage: Pick<Storage, 'setItem' | 'removeItem' | 'getItem'>;
|
|
87
|
+
csrfTokenStore: CsrfTokenStore;
|
|
88
88
|
}>;
|
|
89
89
|
}>;
|
|
90
90
|
|
|
@@ -130,20 +130,14 @@ export class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject =
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
/** Wraps {@link getCurrentCsrfToken} to automatically handle wiping an invalid CSRF token. */
|
|
133
|
-
public getCurrentCsrfToken(): string | undefined {
|
|
134
|
-
const csrfTokenResult = getCurrentCsrfToken({
|
|
133
|
+
public async getCurrentCsrfToken(): Promise<string | undefined> {
|
|
134
|
+
const csrfTokenResult = await getCurrentCsrfToken({
|
|
135
135
|
...this.config.csrf,
|
|
136
|
-
|
|
136
|
+
csrfTokenStore: this.config.overrides?.csrfTokenStore,
|
|
137
137
|
allowedClockSkew: this.config.allowedClockSkew || defaultAllowedClockSkew,
|
|
138
138
|
});
|
|
139
139
|
|
|
140
140
|
if (csrfTokenResult.failure) {
|
|
141
|
-
if (csrfTokenResult.failure !== CsrfTokenFailureReason.DoesNotExist) {
|
|
142
|
-
wipeCurrentCsrfToken({
|
|
143
|
-
...this.config.csrf,
|
|
144
|
-
localStorage: this.config.overrides?.localStorage,
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
141
|
return undefined;
|
|
148
142
|
}
|
|
149
143
|
|
|
@@ -164,9 +158,7 @@ export class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject =
|
|
|
164
158
|
if (!assumedUserParams) {
|
|
165
159
|
localStorage.removeItem(storageKey);
|
|
166
160
|
return true;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
if (!(await this.config.canAssumeUser?.())) {
|
|
161
|
+
} else if (!(await this.config.canAssumeUser?.())) {
|
|
170
162
|
return false;
|
|
171
163
|
}
|
|
172
164
|
|
|
@@ -197,8 +189,8 @@ export class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject =
|
|
|
197
189
|
* `@augment-vir/common`](https://electrovir.github.io/augment-vir/functions/mergeDeep.html) to
|
|
198
190
|
* combine them with these.
|
|
199
191
|
*/
|
|
200
|
-
public createAuthenticatedRequestInit(): RequestInit {
|
|
201
|
-
const csrfToken = this.getCurrentCsrfToken();
|
|
192
|
+
public async createAuthenticatedRequestInit(): Promise<RequestInit> {
|
|
193
|
+
const csrfToken = await this.getCurrentCsrfToken();
|
|
202
194
|
|
|
203
195
|
const assumedUser = this.getAssumedUser();
|
|
204
196
|
const headers: HeadersInit = {
|
|
@@ -224,10 +216,6 @@ export class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject =
|
|
|
224
216
|
/** Wipes the current user auth. */
|
|
225
217
|
public async logout() {
|
|
226
218
|
await this.config.authClearedCallback?.();
|
|
227
|
-
wipeCurrentCsrfToken({
|
|
228
|
-
...this.config.csrf,
|
|
229
|
-
localStorage: this.config.overrides?.localStorage,
|
|
230
|
-
});
|
|
231
219
|
}
|
|
232
220
|
|
|
233
221
|
/**
|
|
@@ -261,9 +249,9 @@ export class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject =
|
|
|
261
249
|
throw new Error('Did not receive any CSRF token.');
|
|
262
250
|
}
|
|
263
251
|
|
|
264
|
-
storeCsrfToken(csrfToken, {
|
|
252
|
+
await storeCsrfToken(csrfToken, {
|
|
265
253
|
...this.config.csrf,
|
|
266
|
-
|
|
254
|
+
csrfTokenStore: this.config.overrides?.csrfTokenStore,
|
|
267
255
|
});
|
|
268
256
|
}
|
|
269
257
|
|
|
@@ -294,17 +282,6 @@ export class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject =
|
|
|
294
282
|
return false;
|
|
295
283
|
}
|
|
296
284
|
|
|
297
|
-
/** If the response has a new CSRF token, store it. */
|
|
298
|
-
const {csrfToken} = extractCsrfTokenHeader(response, this.config.csrf, {
|
|
299
|
-
allowedClockSkew: this.config.allowedClockSkew || defaultAllowedClockSkew,
|
|
300
|
-
});
|
|
301
|
-
if (csrfToken) {
|
|
302
|
-
storeCsrfToken(csrfToken, {
|
|
303
|
-
...this.config.csrf,
|
|
304
|
-
localStorage: this.config.overrides?.localStorage,
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
|
-
|
|
308
285
|
return true;
|
|
309
286
|
}
|
|
310
287
|
}
|
package/src/auth.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
extractCookieJwt,
|
|
8
8
|
generateAuthCookie,
|
|
9
9
|
} from './cookie.js';
|
|
10
|
+
import {type CsrfTokenStore} from './csrf-token-store.js';
|
|
10
11
|
import {
|
|
11
12
|
type CsrfHeaderNameOption,
|
|
12
13
|
extractCsrfTokenHeader,
|
|
@@ -14,7 +15,6 @@ import {
|
|
|
14
15
|
parseCsrfToken,
|
|
15
16
|
resolveCsrfHeaderName,
|
|
16
17
|
storeCsrfToken,
|
|
17
|
-
wipeCurrentCsrfToken,
|
|
18
18
|
} from './csrf-token.js';
|
|
19
19
|
import {type ParseJwtParams} from './jwt/jwt.js';
|
|
20
20
|
import {type JwtUserData} from './jwt/user-jwt.js';
|
|
@@ -202,48 +202,42 @@ export async function generateSuccessfulLoginHeaders(
|
|
|
202
202
|
*/
|
|
203
203
|
export function generateLogoutHeaders(
|
|
204
204
|
cookieConfig: Readonly<Pick<CookieParams, 'cookieName' | 'hostOrigin' | 'isDev'>>,
|
|
205
|
-
csrfHeaderNameOption: Readonly<CsrfHeaderNameOption>,
|
|
206
205
|
): Record<string, string> {
|
|
207
|
-
const csrfHeaderName = resolveCsrfHeaderName(csrfHeaderNameOption);
|
|
208
|
-
|
|
209
206
|
return {
|
|
210
207
|
'set-cookie': clearAuthCookie(cookieConfig),
|
|
211
|
-
[csrfHeaderName]: 'redacted',
|
|
212
208
|
};
|
|
213
209
|
}
|
|
214
210
|
|
|
215
211
|
/**
|
|
216
212
|
* Store auth data on a client (frontend) after receiving an auth response from the host (backend).
|
|
217
|
-
* Specifically, this stores the CSRF token into
|
|
218
|
-
* Alternatively, if the given response failed, this will wipe the existing (if
|
|
213
|
+
* Specifically, this stores the CSRF token into IndexedDB (which doesn't need to be a secret).
|
|
214
|
+
* Alternatively, if the given response failed, this will wipe the existing (if any) stored CSRF
|
|
219
215
|
* token.
|
|
220
216
|
*
|
|
221
217
|
* @category Auth : Client
|
|
222
218
|
* @throws Error if no CSRF token header is found.
|
|
223
219
|
*/
|
|
224
|
-
export function handleAuthResponse(
|
|
220
|
+
export async function handleAuthResponse(
|
|
225
221
|
response: Readonly<Pick<Response, 'ok' | 'headers'>>,
|
|
226
222
|
options: Readonly<CsrfHeaderNameOption> &
|
|
227
223
|
PartialWithUndefined<{
|
|
228
224
|
/**
|
|
229
|
-
* Allows mocking or overriding the
|
|
225
|
+
* Allows mocking or overriding the default CSRF token store.
|
|
230
226
|
*
|
|
231
|
-
* @default
|
|
227
|
+
* @default getDefaultCsrfTokenStore()
|
|
232
228
|
*/
|
|
233
|
-
|
|
229
|
+
csrfTokenStore: CsrfTokenStore;
|
|
234
230
|
}>,
|
|
235
|
-
) {
|
|
231
|
+
): Promise<void> {
|
|
236
232
|
if (!response.ok) {
|
|
237
|
-
wipeCurrentCsrfToken(options);
|
|
238
233
|
return;
|
|
239
234
|
}
|
|
240
235
|
|
|
241
236
|
const {csrfToken} = extractCsrfTokenHeader(response, options);
|
|
242
237
|
|
|
243
238
|
if (!csrfToken) {
|
|
244
|
-
wipeCurrentCsrfToken(options);
|
|
245
239
|
throw new Error('Did not receive any CSRF token.');
|
|
246
240
|
}
|
|
247
241
|
|
|
248
|
-
storeCsrfToken(csrfToken, options);
|
|
242
|
+
await storeCsrfToken(csrfToken, options);
|
|
249
243
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {LocalDbClient} from 'local-db-client';
|
|
2
|
+
import {defineShape} from 'object-shape-tester';
|
|
3
|
+
|
|
4
|
+
const csrfTokenDbShapes = {
|
|
5
|
+
csrfToken: defineShape(''),
|
|
6
|
+
} as const;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The interface used for overriding the default CSRF token store in storage functions.
|
|
10
|
+
*
|
|
11
|
+
* @category Internal
|
|
12
|
+
*/
|
|
13
|
+
export type CsrfTokenStore = {
|
|
14
|
+
/** Retrieves the stored CSRF token, if any. */
|
|
15
|
+
getCsrfToken(): Promise<string | undefined>;
|
|
16
|
+
/** Stores a CSRF token. */
|
|
17
|
+
setCsrfToken(value: string): Promise<void>;
|
|
18
|
+
/** Deletes the stored CSRF token. */
|
|
19
|
+
deleteCsrfToken(): Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
async function createDefaultCsrfTokenStore(): Promise<CsrfTokenStore> {
|
|
23
|
+
const client = await LocalDbClient.createClient(csrfTokenDbShapes, {
|
|
24
|
+
storeName: 'auth-vir-csrf',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
async getCsrfToken() {
|
|
29
|
+
return (await client.load.csrfToken()) || undefined;
|
|
30
|
+
},
|
|
31
|
+
async setCsrfToken(value) {
|
|
32
|
+
await client.set.csrfToken(value);
|
|
33
|
+
},
|
|
34
|
+
async deleteCsrfToken() {
|
|
35
|
+
await client.delete.csrfToken();
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The default {@link LocalDbClient} instance used for storing CSRF tokens. This uses a dedicated
|
|
42
|
+
* store name to avoid collisions with other storage. Lazily initialized to avoid crashes in Node.js
|
|
43
|
+
* environments where IndexedDB is not available.
|
|
44
|
+
*
|
|
45
|
+
* @category Internal
|
|
46
|
+
*/
|
|
47
|
+
export async function getDefaultCsrfTokenStore(): Promise<CsrfTokenStore> {
|
|
48
|
+
if (!cachedStorePromise) {
|
|
49
|
+
cachedStorePromise = createDefaultCsrfTokenStore();
|
|
50
|
+
}
|
|
51
|
+
return cachedStorePromise;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let cachedStorePromise: Promise<CsrfTokenStore> | undefined;
|
package/src/csrf-token.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from 'date-vir';
|
|
14
14
|
import {defineShape, parseJsonWithShape} from 'object-shape-tester';
|
|
15
15
|
import {type RequireExactlyOne} from 'type-fest';
|
|
16
|
+
import {getDefaultCsrfTokenStore, type CsrfTokenStore} from './csrf-token-store.js';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Shape definition for {@link CsrfToken}.
|
|
@@ -137,24 +138,23 @@ export function extractCsrfTokenHeader(
|
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
/**
|
|
140
|
-
* Stores the given CSRF token into
|
|
141
|
+
* Stores the given CSRF token into IndexedDB.
|
|
141
142
|
*
|
|
142
143
|
* @category Auth : Client
|
|
143
144
|
*/
|
|
144
|
-
export function storeCsrfToken(
|
|
145
|
+
export async function storeCsrfToken(
|
|
145
146
|
csrfToken: Readonly<CsrfToken>,
|
|
146
147
|
options: Readonly<CsrfHeaderNameOption> &
|
|
147
148
|
PartialWithUndefined<{
|
|
148
149
|
/**
|
|
149
|
-
* Allows mocking or overriding the
|
|
150
|
+
* Allows mocking or overriding the default CSRF token store.
|
|
150
151
|
*
|
|
151
|
-
* @default
|
|
152
|
+
* @default getDefaultCsrfTokenStore()
|
|
152
153
|
*/
|
|
153
|
-
|
|
154
|
+
csrfTokenStore: CsrfTokenStore;
|
|
154
155
|
}>,
|
|
155
|
-
) {
|
|
156
|
-
(options.
|
|
157
|
-
resolveCsrfHeaderName(options),
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
await (options.csrfTokenStore || (await getDefaultCsrfTokenStore())).setCsrfToken(
|
|
158
158
|
JSON.stringify(csrfToken),
|
|
159
159
|
);
|
|
160
160
|
}
|
|
@@ -226,15 +226,15 @@ export function parseCsrfToken(
|
|
|
226
226
|
*
|
|
227
227
|
* @category Auth : Client
|
|
228
228
|
*/
|
|
229
|
-
export function getCurrentCsrfToken(
|
|
229
|
+
export async function getCurrentCsrfToken(
|
|
230
230
|
options: Readonly<CsrfHeaderNameOption> &
|
|
231
231
|
PartialWithUndefined<{
|
|
232
232
|
/**
|
|
233
|
-
* Allows mocking or overriding the
|
|
233
|
+
* Allows mocking or overriding the default CSRF token store.
|
|
234
234
|
*
|
|
235
|
-
* @default
|
|
235
|
+
* @default getDefaultCsrfTokenStore()
|
|
236
236
|
*/
|
|
237
|
-
|
|
237
|
+
csrfTokenStore: CsrfTokenStore;
|
|
238
238
|
/**
|
|
239
239
|
* Allowed clock skew tolerance for CSRF token expiration checks.
|
|
240
240
|
*
|
|
@@ -242,32 +242,30 @@ export function getCurrentCsrfToken(
|
|
|
242
242
|
*/
|
|
243
243
|
allowedClockSkew: Readonly<AnyDuration>;
|
|
244
244
|
}>,
|
|
245
|
-
): Readonly<GetCsrfTokenResult
|
|
245
|
+
): Promise<Readonly<GetCsrfTokenResult>> {
|
|
246
246
|
const rawCsrfToken: string | undefined =
|
|
247
|
-
(options.
|
|
247
|
+
(await (options.csrfTokenStore || (await getDefaultCsrfTokenStore())).getCsrfToken()) ||
|
|
248
248
|
undefined;
|
|
249
249
|
|
|
250
250
|
return parseCsrfToken(rawCsrfToken, options);
|
|
251
251
|
}
|
|
252
252
|
|
|
253
253
|
/**
|
|
254
|
-
* Wipes the current stored CSRF token. This should be used by client (frontend) code to
|
|
255
|
-
*
|
|
254
|
+
* Wipes the current stored CSRF token. This should be used by client (frontend) code to react to a
|
|
255
|
+
* session timeout.
|
|
256
256
|
*
|
|
257
257
|
* @category Auth : Client
|
|
258
258
|
*/
|
|
259
|
-
export function wipeCurrentCsrfToken(
|
|
259
|
+
export async function wipeCurrentCsrfToken(
|
|
260
260
|
options: Readonly<CsrfHeaderNameOption> &
|
|
261
261
|
PartialWithUndefined<{
|
|
262
262
|
/**
|
|
263
|
-
* Allows mocking or overriding the
|
|
263
|
+
* Allows mocking or overriding the default CSRF token store.
|
|
264
264
|
*
|
|
265
|
-
* @default
|
|
265
|
+
* @default getDefaultCsrfTokenStore()
|
|
266
266
|
*/
|
|
267
|
-
|
|
267
|
+
csrfTokenStore: CsrfTokenStore;
|
|
268
268
|
}>,
|
|
269
|
-
) {
|
|
270
|
-
|
|
271
|
-
resolveCsrfHeaderName(options),
|
|
272
|
-
);
|
|
269
|
+
): Promise<void> {
|
|
270
|
+
await (options.csrfTokenStore || (await getDefaultCsrfTokenStore())).deleteCsrfToken();
|
|
273
271
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,10 +3,11 @@ export * from './auth-client/frontend-auth.client.js';
|
|
|
3
3
|
export * from './auth-client/is-session-refresh-ready.js';
|
|
4
4
|
export * from './auth.js';
|
|
5
5
|
export * from './cookie.js';
|
|
6
|
+
export * from './csrf-token-store.js';
|
|
6
7
|
export * from './csrf-token.js';
|
|
7
8
|
export * from './hash.js';
|
|
8
9
|
export * from './headers.js';
|
|
9
10
|
export * from './jwt/jwt-keys.js';
|
|
10
11
|
export * from './jwt/jwt.js';
|
|
11
12
|
export * from './jwt/user-jwt.js';
|
|
12
|
-
export * from './mock-
|
|
13
|
+
export * from './mock-csrf-token-store.js';
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {type CsrfTokenStore} from './csrf-token-store.js';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* `accessRecord` type for {@link createMockLocalStorage}'s output.
|
|
3
5
|
*
|
|
@@ -73,3 +75,67 @@ export function createMockLocalStorage(
|
|
|
73
75
|
accessRecord,
|
|
74
76
|
};
|
|
75
77
|
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `accessRecord` type for {@link createMockCsrfTokenStore}'s output.
|
|
81
|
+
*
|
|
82
|
+
* @category Internal
|
|
83
|
+
*/
|
|
84
|
+
export type MockCsrfTokenStoreAccessRecord = {
|
|
85
|
+
getCsrfToken: number;
|
|
86
|
+
setCsrfToken: string[];
|
|
87
|
+
deleteCsrfToken: number;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Create an empty `accessRecord` object, this is to be used in conjunction with
|
|
92
|
+
* {@link createMockCsrfTokenStore}.
|
|
93
|
+
*
|
|
94
|
+
* @category Mock
|
|
95
|
+
*/
|
|
96
|
+
export function createEmptyMockCsrfTokenStoreAccessRecord(): MockCsrfTokenStoreAccessRecord {
|
|
97
|
+
return {
|
|
98
|
+
getCsrfToken: 0,
|
|
99
|
+
setCsrfToken: [],
|
|
100
|
+
deleteCsrfToken: 0,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Create a mock {@link CsrfTokenStore} backed by a simple in-memory object, for use in tests.
|
|
106
|
+
*
|
|
107
|
+
* @category Mock
|
|
108
|
+
*/
|
|
109
|
+
export function createMockCsrfTokenStore(
|
|
110
|
+
/** Set an initial value to initialize the mocked store contents. */
|
|
111
|
+
init?: string | undefined,
|
|
112
|
+
) {
|
|
113
|
+
let storedValue: string | undefined = init;
|
|
114
|
+
const accessRecord = createEmptyMockCsrfTokenStoreAccessRecord();
|
|
115
|
+
|
|
116
|
+
const csrfTokenStore: CsrfTokenStore = {
|
|
117
|
+
getCsrfToken() {
|
|
118
|
+
accessRecord.getCsrfToken++;
|
|
119
|
+
return Promise.resolve(storedValue);
|
|
120
|
+
},
|
|
121
|
+
setCsrfToken(value: string) {
|
|
122
|
+
accessRecord.setCsrfToken.push(value);
|
|
123
|
+
storedValue = value;
|
|
124
|
+
return Promise.resolve();
|
|
125
|
+
},
|
|
126
|
+
deleteCsrfToken() {
|
|
127
|
+
accessRecord.deleteCsrfToken++;
|
|
128
|
+
storedValue = undefined;
|
|
129
|
+
return Promise.resolve();
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
csrfTokenStore,
|
|
135
|
+
/** The current value held in the mock store. */
|
|
136
|
+
get storedValue() {
|
|
137
|
+
return storedValue;
|
|
138
|
+
},
|
|
139
|
+
accessRecord,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `accessRecord` type for {@link createMockLocalStorage}'s output.
|
|
3
|
-
*
|
|
4
|
-
* @category Internal
|
|
5
|
-
*/
|
|
6
|
-
export type MockLocalStorageAccessRecord = {
|
|
7
|
-
getItem: string[];
|
|
8
|
-
removeItem: string[];
|
|
9
|
-
setItem: {
|
|
10
|
-
key: string;
|
|
11
|
-
value: string;
|
|
12
|
-
}[];
|
|
13
|
-
key: number[];
|
|
14
|
-
};
|
|
15
|
-
/**
|
|
16
|
-
* Create an empty `accessRecord` object, this is to be used in conjunction with
|
|
17
|
-
* {@link createMockLocalStorage}.
|
|
18
|
-
*
|
|
19
|
-
* @category Mock
|
|
20
|
-
*/
|
|
21
|
-
export declare function createEmptyMockLocalStorageAccessRecord(): MockLocalStorageAccessRecord;
|
|
22
|
-
/**
|
|
23
|
-
* Create a LocalStorage mock.
|
|
24
|
-
*
|
|
25
|
-
* @category Mock
|
|
26
|
-
*/
|
|
27
|
-
export declare function createMockLocalStorage(
|
|
28
|
-
/** Set values in here to initialize the mocked localStorage data store contents. */
|
|
29
|
-
init?: Record<string, string>): {
|
|
30
|
-
localStorage: Storage;
|
|
31
|
-
store: Record<string, string>;
|
|
32
|
-
accessRecord: MockLocalStorageAccessRecord;
|
|
33
|
-
};
|