auth-vir 3.1.0 → 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 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
- }), this.config.csrf)
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
- }), this.config.csrf)
265
+ }))
266
266
  : undefined;
267
- const setCookieHeader = {
267
+ return {
268
268
  'set-cookie': mergeHeaderValues(signUpCookieHeaders?.['set-cookie'], authCookieHeaders?.['set-cookie']),
269
269
  };
270
- const csrfTokenHeader = {
271
- ...authCookieHeaders,
272
- ...signUpCookieHeaders,
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
- ...csrfTokenHeader,
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
- }), this.config.csrf)
293
+ }))
288
294
  : undefined;
289
295
  const existingUserIdResult = await extractUserIdFromRequestHeaders(requestHeaders, await this.getJwtParams(), this.config.csrf, isSignUpCookie ? AuthCookieName.SignUp : AuthCookieName.Auth);
290
- const sessionStartedAt = existingUserIdResult?.sessionStartedAt;
291
- const newCookieHeaders = await generateSuccessfulLoginHeaders(userId, await this.getCookieParams({
296
+ const cookieParams = await this.getCookieParams({
292
297
  isSignUpCookie,
293
298
  requestHeaders,
294
- }), this.config.csrf, sessionStartedAt);
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 { CsrfTokenFailureReason, defaultAllowedClockSkew, extractCsrfTokenHeader, getCurrentCsrfToken, resolveCsrfHeaderName, storeCsrfToken, wipeCurrentCsrfToken, } from '../csrf-token.js';
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
- localStorage: this.config.overrides?.localStorage,
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
- localStorage: this.config.overrides?.localStorage,
137
+ csrfTokenStore: this.config.overrides?.csrfTokenStore,
148
138
  });
149
139
  }
150
140
  /**
@@ -164,9 +154,9 @@ export class FrontendAuthClient {
164
154
  allowedClockSkew: this.config.allowedClockSkew || defaultAllowedClockSkew,
165
155
  });
166
156
  if (csrfToken) {
167
- storeCsrfToken(csrfToken, {
157
+ await storeCsrfToken(csrfToken, {
168
158
  ...this.config.csrf,
169
- localStorage: this.config.overrides?.localStorage,
159
+ csrfTokenStore: this.config.overrides?.csrfTokenStore,
170
160
  });
171
161
  }
172
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'>>, csrfHeaderNameOption: Readonly<CsrfHeaderNameOption>): Record<string, string>;
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 local storage (which doesn't need to be a secret).
73
- * Alternatively, if the given response failed, this will wipe the existing (if anyone) stored CSRF
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 global `localStorage`.
82
+ * Allows mocking or overriding the default CSRF token store.
82
83
  *
83
- * @default globalThis.localStorage
84
+ * @default getDefaultCsrfTokenStore()
84
85
  */
85
- localStorage: Pick<Storage, 'setItem' | 'removeItem'>;
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, wipeCurrentCsrfToken, } from './csrf-token.js';
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, csrfHeaderNameOption) {
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 local storage (which doesn't need to be a secret).
135
- * Alternatively, if the given response failed, this will wipe the existing (if anyone) stored CSRF
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;
@@ -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 local storage.
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 global `localStorage`.
110
+ * Allows mocking or overriding the default CSRF token store.
110
111
  *
111
- * @default globalThis.localStorage
112
+ * @default getDefaultCsrfTokenStore()
112
113
  */
113
- localStorage: Pick<Storage, 'setItem' | 'removeItem'>;
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 global `localStorage`.
138
+ * Allows mocking or overriding the default CSRF token store.
138
139
  *
139
- * @default globalThis.localStorage
140
+ * @default getDefaultCsrfTokenStore()
140
141
  */
141
- localStorage: Pick<Storage, 'getItem'>;
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 logout a
151
- * user or react to a session timeout.
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 global `localStorage`.
158
+ * Allows mocking or overriding the default CSRF token store.
158
159
  *
159
- * @default globalThis.localStorage
160
+ * @default getDefaultCsrfTokenStore()
160
161
  */
161
- localStorage: Pick<Storage, 'removeItem'>;
162
- }>): void;
162
+ csrfTokenStore: CsrfTokenStore;
163
+ }>): Promise<void>;
@@ -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 local storage.
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.localStorage || globalThis.localStorage).setItem(resolveCsrfHeaderName(options), JSON.stringify(csrfToken));
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.localStorage || globalThis.localStorage).getItem(resolveCsrfHeaderName(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 logout a
134
- * user or react to a session timeout.
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
- return (options.localStorage || globalThis.localStorage).removeItem(resolveCsrfHeaderName(options));
139
+ export async function wipeCurrentCsrfToken(options) {
140
+ await (options.csrfTokenStore || (await getDefaultCsrfTokenStore())).deleteCsrfToken();
140
141
  }
@@ -22,8 +22,8 @@ const config = {
22
22
  "fromEnvVar": null
23
23
  },
24
24
  "config": {
25
- "engineType": "client",
26
- "moduleFormat": "esm"
25
+ "moduleFormat": "esm",
26
+ "engineType": "client"
27
27
  },
28
28
  "binaryTargets": [
29
29
  {
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-local-storage.js';
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-local-storage.js';
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.0",
3
+ "version": "3.1.1",
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.67.1",
45
- "@augment-vir/common": "^31.67.1",
46
- "date-vir": "^8.2.0",
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.3",
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.67.1",
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
- const setCookieHeader: {
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
- const csrfTokenHeader = {
601
- ...authCookieHeaders,
602
- ...signUpCookieHeaders,
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
- ...csrfTokenHeader,
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 newCookieHeaders = await generateSuccessfulLoginHeaders(
643
- userId,
644
- await this.getCookieParams({
645
- isSignUpCookie,
646
- requestHeaders,
647
- }),
648
- this.config.csrf,
649
- sessionStartedAt,
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
- localStorage: this.config.overrides?.localStorage,
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
- localStorage: this.config.overrides?.localStorage,
254
+ csrfTokenStore: this.config.overrides?.csrfTokenStore,
267
255
  });
268
256
  }
269
257
 
@@ -299,9 +287,9 @@ export class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject =
299
287
  allowedClockSkew: this.config.allowedClockSkew || defaultAllowedClockSkew,
300
288
  });
301
289
  if (csrfToken) {
302
- storeCsrfToken(csrfToken, {
290
+ await storeCsrfToken(csrfToken, {
303
291
  ...this.config.csrf,
304
- localStorage: this.config.overrides?.localStorage,
292
+ csrfTokenStore: this.config.overrides?.csrfTokenStore,
305
293
  });
306
294
  }
307
295
 
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 local storage (which doesn't need to be a secret).
218
- * Alternatively, if the given response failed, this will wipe the existing (if anyone) stored CSRF
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 global `localStorage`.
225
+ * Allows mocking or overriding the default CSRF token store.
230
226
  *
231
- * @default globalThis.localStorage
227
+ * @default getDefaultCsrfTokenStore()
232
228
  */
233
- localStorage: Pick<Storage, 'setItem' | 'removeItem'>;
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 local storage.
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 global `localStorage`.
150
+ * Allows mocking or overriding the default CSRF token store.
150
151
  *
151
- * @default globalThis.localStorage
152
+ * @default getDefaultCsrfTokenStore()
152
153
  */
153
- localStorage: Pick<Storage, 'setItem' | 'removeItem'>;
154
+ csrfTokenStore: CsrfTokenStore;
154
155
  }>,
155
- ) {
156
- (options.localStorage || globalThis.localStorage).setItem(
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 global `localStorage`.
233
+ * Allows mocking or overriding the default CSRF token store.
234
234
  *
235
- * @default globalThis.localStorage
235
+ * @default getDefaultCsrfTokenStore()
236
236
  */
237
- localStorage: Pick<Storage, 'getItem'>;
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.localStorage || globalThis.localStorage).getItem(resolveCsrfHeaderName(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 logout a
255
- * user or react to a session timeout.
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 global `localStorage`.
263
+ * Allows mocking or overriding the default CSRF token store.
264
264
  *
265
- * @default globalThis.localStorage
265
+ * @default getDefaultCsrfTokenStore()
266
266
  */
267
- localStorage: Pick<Storage, 'removeItem'>;
267
+ csrfTokenStore: CsrfTokenStore;
268
268
  }>,
269
- ) {
270
- return (options.localStorage || globalThis.localStorage).removeItem(
271
- resolveCsrfHeaderName(options),
272
- );
269
+ ): Promise<void> {
270
+ await (options.csrfTokenStore || (await getDefaultCsrfTokenStore())).deleteCsrfToken();
273
271
  }
@@ -27,8 +27,8 @@ const config: runtime.GetPrismaClientConfig = {
27
27
  "fromEnvVar": null
28
28
  },
29
29
  "config": {
30
- "engineType": "client",
31
- "moduleFormat": "esm"
30
+ "moduleFormat": "esm",
31
+ "engineType": "client"
32
32
  },
33
33
  "binaryTargets": [
34
34
  {
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-local-storage.js';
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
- };