auth-vir 3.0.1 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -252,8 +252,7 @@ Use this on your client / frontend for storing and sending session authorization
252
252
 
253
253
  1. Send a login fetch request to your host / server / backend with `{credentials: 'include'}` set on the request.
254
254
  2. Pass the `Response` from step 1 into [`handleAuthResponse`](https://electrovir.github.io/auth-vir/functions/handleAuthResponse.html).
255
- 3. In all subsequent fetch requests to the host / server / backend, set `{credentials: 'include'}` and include `{headers: {[AuthHeaderName.CsrfToken]: getCurrentCsrfToken()}}`.
256
- 4. Upon user logout, call [`wipeCurrentCsrfToken()`](https://electrovir.github.io/auth-vir/functions/wipeCurrentCsrfToken.html)
255
+ 3. In all subsequent fetch requests to the host / server / backend, set `{credentials: 'include'}` and include `{headers: {[AuthHeaderName.CsrfToken]: (await getCurrentCsrfToken()).csrfToken}}`.
257
256
 
258
257
  Here's a full example of how to use all the client / frontend side auth functionality:
259
258
 
@@ -282,7 +281,7 @@ export async function sendLoginRequest(
282
281
  userLoginData: {username: string; password: string},
283
282
  loginUrl: string,
284
283
  ) {
285
- if (getCurrentCsrfToken(csrfOption).csrfToken) {
284
+ if ((await getCurrentCsrfToken(csrfOption)).csrfToken) {
286
285
  throw new Error('Already logged in.');
287
286
  }
288
287
 
@@ -292,7 +291,7 @@ export async function sendLoginRequest(
292
291
  credentials: 'include',
293
292
  });
294
293
 
295
- handleAuthResponse(response, csrfOption);
294
+ await handleAuthResponse(response, csrfOption);
296
295
 
297
296
  return response;
298
297
  }
@@ -303,7 +302,7 @@ export async function sendAuthenticatedRequest(
303
302
  requestInit: Omit<RequestInit, 'headers'> = {},
304
303
  headers: Record<string, string> = {},
305
304
  ) {
306
- const {csrfToken} = getCurrentCsrfToken(csrfOption);
305
+ const {csrfToken} = await getCurrentCsrfToken(csrfOption);
307
306
 
308
307
  if (!csrfToken) {
309
308
  throw new Error('Not authenticated.');
@@ -324,7 +323,7 @@ export async function sendAuthenticatedRequest(
324
323
  * another tab.)
325
324
  */
326
325
  if (response.status === HttpStatus.Unauthorized) {
327
- wipeCurrentCsrfToken(csrfOption);
326
+ await wipeCurrentCsrfToken(csrfOption);
328
327
  throw new Error(`User no longer logged in.`);
329
328
  } else {
330
329
  return response;
@@ -332,8 +331,8 @@ export async function sendAuthenticatedRequest(
332
331
  }
333
332
 
334
333
  /** Call this when the user explicitly clicks a "log out" button. */
335
- export function logout() {
336
- wipeCurrentCsrfToken(csrfOption);
334
+ export async function logout() {
335
+ await wipeCurrentCsrfToken(csrfOption);
337
336
  }
338
337
  ```
339
338
 
@@ -60,6 +60,12 @@ export type BackendAuthClientConfig<DatabaseUser extends AnyObject, UserId exten
60
60
  */
61
61
  isDev: boolean;
62
62
  } & PartialWithUndefined<{
63
+ /** If this returns true, logging will be enabled while handling the relevant session. */
64
+ enableLogging(params: {
65
+ user: DatabaseUser | undefined;
66
+ userId: UserId | undefined;
67
+ assumedUserParams: AssumedUserParams | undefined;
68
+ }): boolean;
63
69
  /**
64
70
  * Overwrite the header name used for tracking is an admin is assuming the identity of
65
71
  * another user.
@@ -72,6 +78,8 @@ export type BackendAuthClientConfig<DatabaseUser extends AnyObject, UserId exten
72
78
  generateServiceOrigin(params: {
73
79
  requestHeaders: Readonly<IncomingHttpHeaders>;
74
80
  }): MaybePromise<undefined | string>;
81
+ /** If provided, logs will be sent to this method. */
82
+ log?: (message: string, extraData: AnyObject) => void;
75
83
  /**
76
84
  * Set this to allow specific users (determined by `canAssumeUser`) to assume the identity
77
85
  * of other users. This should only be used for admins so that they can troubleshoot user
@@ -141,6 +149,12 @@ export declare class BackendAuthClient<DatabaseUser extends AnyObject, UserId ex
141
149
  protected readonly config: BackendAuthClientConfig<DatabaseUser, UserId, AssumedUserParams>;
142
150
  protected cachedParsedJwtKeys: Record<string, Readonly<JwtKeys>>;
143
151
  constructor(config: BackendAuthClientConfig<DatabaseUser, UserId, AssumedUserParams>);
152
+ /** Conditionally logs a message if logging is enabled for the given user context. */
153
+ protected logForUser(params: {
154
+ user: DatabaseUser | undefined;
155
+ userId: UserId | undefined;
156
+ assumedUserParams: AssumedUserParams | undefined;
157
+ }, message: string, extra?: Record<string, unknown>): void;
144
158
  /** Get all the parameters used for cookie generation. */
145
159
  protected getCookieParams({ isSignUpCookie, requestHeaders, }: {
146
160
  /**
@@ -195,6 +209,15 @@ export declare class BackendAuthClient<DatabaseUser extends AnyObject, UserId ex
195
209
  }>): Promise<Record<string, string | string[]> & {
196
210
  'set-cookie': string[];
197
211
  }>;
212
+ /**
213
+ * Refreshes a login session by reissuing the auth cookie with the same CSRF token instead of
214
+ * generating a new one.
215
+ */
216
+ protected refreshLoginHeaders({ userId, cookieParams, existingUserIdResult, }: {
217
+ userId: UserId;
218
+ cookieParams: Readonly<CookieParams>;
219
+ existingUserIdResult: Readonly<UserIdResult<UserId>>;
220
+ }): Promise<Record<string, string>>;
198
221
  /** Use these headers to log a user in. */
199
222
  createLoginHeaders({ userId, requestHeaders, isSignUpCookie, }: {
200
223
  userId: UserId;
@@ -28,10 +28,27 @@ export class BackendAuthClient {
28
28
  constructor(config) {
29
29
  this.config = config;
30
30
  }
31
+ /** Conditionally logs a message if logging is enabled for the given user context. */
32
+ logForUser(params, message, extra) {
33
+ if (this.config.enableLogging?.(params)) {
34
+ const extraData = {
35
+ userId: params.userId,
36
+ ...extra,
37
+ };
38
+ if (this.config.log) {
39
+ this.config.log(message, extraData);
40
+ }
41
+ else {
42
+ console.info(`[auth-vir] ${message}`, extraData);
43
+ }
44
+ }
45
+ }
31
46
  /** Get all the parameters used for cookie generation. */
32
47
  async getCookieParams({ isSignUpCookie, requestHeaders, }) {
33
48
  const serviceOrigin = requestHeaders
34
- ? await this.config.generateServiceOrigin?.({ requestHeaders })
49
+ ? await this.config.generateServiceOrigin?.({
50
+ requestHeaders,
51
+ })
35
52
  : undefined;
36
53
  return {
37
54
  cookieDuration: this.config.userSessionIdleTimeout || defaultSessionIdleTimeout,
@@ -53,6 +70,13 @@ export class BackendAuthClient {
53
70
  requestHeaders,
54
71
  });
55
72
  if (!authenticatedUser) {
73
+ this.logForUser({
74
+ user: undefined,
75
+ userId,
76
+ assumedUserParams: assumingUser,
77
+ }, 'getUserFromDatabase returned no user', {
78
+ isSignUpCookie,
79
+ });
56
80
  return undefined;
57
81
  }
58
82
  return authenticatedUser;
@@ -67,6 +91,14 @@ export class BackendAuthClient {
67
91
  relativeTo: calculateRelativeDate(userIdResult.jwtExpiration, clockSkew),
68
92
  });
69
93
  if (isExpiredAlready) {
94
+ this.logForUser({
95
+ user: undefined,
96
+ userId: userIdResult.userId,
97
+ assumedUserParams: undefined,
98
+ }, 'Session refresh denied: JWT already expired (even with clock skew tolerance)', {
99
+ jwtExpiration: userIdResult.jwtExpiration,
100
+ now: JSON.stringify(now),
101
+ });
70
102
  return undefined;
71
103
  }
72
104
  /**
@@ -82,6 +114,15 @@ export class BackendAuthClient {
82
114
  relativeTo: maxSessionEndDate,
83
115
  });
84
116
  if (isSessionExpired) {
117
+ this.logForUser({
118
+ user: undefined,
119
+ userId: userIdResult.userId,
120
+ assumedUserParams: undefined,
121
+ }, 'Session refresh denied: max session duration exceeded', {
122
+ sessionStartedAt: userIdResult.sessionStartedAt,
123
+ maxSessionEndDate: JSON.stringify(maxSessionEndDate),
124
+ now: JSON.stringify(now),
125
+ });
85
126
  return undefined;
86
127
  }
87
128
  }
@@ -112,6 +153,14 @@ export class BackendAuthClient {
112
153
  };
113
154
  }
114
155
  else {
156
+ this.logForUser({
157
+ user: undefined,
158
+ userId: userIdResult.userId,
159
+ assumedUserParams: undefined,
160
+ }, 'Session refresh skipped: not yet ready for refresh', {
161
+ jwtIssuedAt: userIdResult.jwtIssuedAt,
162
+ sessionRefreshStartTime,
163
+ });
115
164
  return undefined;
116
165
  }
117
166
  }
@@ -140,6 +189,13 @@ export class BackendAuthClient {
140
189
  async getSecureUser({ requestHeaders, isSignUpCookie, allowUserAuthRefresh, }) {
141
190
  const userIdResult = await extractUserIdFromRequestHeaders(requestHeaders, await this.getJwtParams(), this.config.csrf, isSignUpCookie ? AuthCookieName.SignUp : AuthCookieName.Auth);
142
191
  if (!userIdResult) {
192
+ this.logForUser({
193
+ user: undefined,
194
+ userId: undefined,
195
+ assumedUserParams: undefined,
196
+ }, 'getSecureUser: failed to extract user ID from request headers (invalid JWT, missing cookie, or CSRF mismatch)', {
197
+ isSignUpCookie,
198
+ });
143
199
  return undefined;
144
200
  }
145
201
  const user = await this.getDatabaseUser({
@@ -149,6 +205,13 @@ export class BackendAuthClient {
149
205
  requestHeaders,
150
206
  });
151
207
  if (!user) {
208
+ this.logForUser({
209
+ user: undefined,
210
+ userId: userIdResult.userId,
211
+ assumedUserParams: undefined,
212
+ }, 'getSecureUser: user not found in database', {
213
+ isSignUpCookie,
214
+ });
152
215
  return undefined;
153
216
  }
154
217
  const assumedUser = await this.getAssumedUser({
@@ -175,7 +238,9 @@ export class BackendAuthClient {
175
238
  const cachedParsedKeys = this.cachedParsedJwtKeys[cacheKey];
176
239
  const parsedKeys = cachedParsedKeys || (await parseJwtKeys(rawJwtKeys));
177
240
  if (!cachedParsedKeys) {
178
- this.cachedParsedJwtKeys = { [cacheKey]: parsedKeys };
241
+ this.cachedParsedJwtKeys = {
242
+ [cacheKey]: parsedKeys,
243
+ };
179
244
  }
180
245
  return {
181
246
  jwtKeys: parsedKeys,
@@ -191,24 +256,30 @@ export class BackendAuthClient {
191
256
  ? generateLogoutHeaders(await this.getCookieParams({
192
257
  isSignUpCookie: true,
193
258
  requestHeaders: undefined,
194
- }), this.config.csrf)
259
+ }))
195
260
  : undefined;
196
261
  const authCookieHeaders = params.allCookies || !params.isSignUpCookie
197
262
  ? generateLogoutHeaders(await this.getCookieParams({
198
263
  isSignUpCookie: false,
199
264
  requestHeaders: undefined,
200
- }), this.config.csrf)
265
+ }))
201
266
  : undefined;
202
- const setCookieHeader = {
267
+ return {
203
268
  'set-cookie': mergeHeaderValues(signUpCookieHeaders?.['set-cookie'], authCookieHeaders?.['set-cookie']),
204
269
  };
205
- const csrfTokenHeader = {
206
- ...authCookieHeaders,
207
- ...signUpCookieHeaders,
208
- };
270
+ }
271
+ /**
272
+ * Refreshes a login session by reissuing the auth cookie with the same CSRF token instead of
273
+ * generating a new one.
274
+ */
275
+ async refreshLoginHeaders({ userId, cookieParams, existingUserIdResult, }) {
276
+ const { cookie } = await generateAuthCookie({
277
+ csrfToken: existingUserIdResult.csrfToken,
278
+ userId,
279
+ sessionStartedAt: existingUserIdResult.sessionStartedAt,
280
+ }, cookieParams);
209
281
  return {
210
- ...csrfTokenHeader,
211
- ...setCookieHeader,
282
+ 'set-cookie': cookie,
212
283
  };
213
284
  }
214
285
  /** Use these headers to log a user in. */
@@ -219,14 +290,20 @@ export class BackendAuthClient {
219
290
  ? generateLogoutHeaders(await this.getCookieParams({
220
291
  isSignUpCookie: !isSignUpCookie,
221
292
  requestHeaders,
222
- }), this.config.csrf)
293
+ }))
223
294
  : undefined;
224
295
  const existingUserIdResult = await extractUserIdFromRequestHeaders(requestHeaders, await this.getJwtParams(), this.config.csrf, isSignUpCookie ? AuthCookieName.SignUp : AuthCookieName.Auth);
225
- const sessionStartedAt = existingUserIdResult?.sessionStartedAt;
226
- const newCookieHeaders = await generateSuccessfulLoginHeaders(userId, await this.getCookieParams({
296
+ const cookieParams = await this.getCookieParams({
227
297
  isSignUpCookie,
228
298
  requestHeaders,
229
- }), 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);
230
307
  return {
231
308
  ...newCookieHeaders,
232
309
  'set-cookie': mergeHeaderValues(newCookieHeaders['set-cookie'], discardOppositeCookieHeaders?.['set-cookie']),
@@ -241,11 +318,17 @@ export class BackendAuthClient {
241
318
  async getInsecureOrSecureUser(params) {
242
319
  const secureUser = await this.getSecureUser(params);
243
320
  if (secureUser) {
244
- return { secureUser };
321
+ return {
322
+ secureUser,
323
+ };
245
324
  }
246
325
  // eslint-disable-next-line @typescript-eslint/no-deprecated
247
326
  const insecureUser = await this.getInsecureUser(params);
248
- return insecureUser ? { insecureUser } : {};
327
+ return insecureUser
328
+ ? {
329
+ insecureUser,
330
+ }
331
+ : {};
249
332
  }
250
333
  /**
251
334
  * @deprecated This only half authenticates the user. It should only be used in circumstances
@@ -256,6 +339,11 @@ export class BackendAuthClient {
256
339
  // eslint-disable-next-line @typescript-eslint/no-deprecated
257
340
  const userIdResult = await insecureExtractUserIdFromCookieAlone(requestHeaders, await this.getJwtParams(), AuthCookieName.Auth);
258
341
  if (!userIdResult) {
342
+ this.logForUser({
343
+ user: undefined,
344
+ userId: undefined,
345
+ assumedUserParams: undefined,
346
+ }, 'getInsecureUser: failed to extract user ID from cookie (invalid JWT or missing cookie)');
259
347
  return undefined;
260
348
  }
261
349
  const user = await this.getDatabaseUser({
@@ -265,6 +353,11 @@ export class BackendAuthClient {
265
353
  requestHeaders,
266
354
  });
267
355
  if (!user) {
356
+ this.logForUser({
357
+ user: undefined,
358
+ userId: userIdResult.userId,
359
+ assumedUserParams: undefined,
360
+ }, 'getInsecureUser: user not found in database');
268
361
  return undefined;
269
362
  }
270
363
  const refreshHeaders = allowUserAuthRefresh &&
@@ -1,6 +1,7 @@
1
1
  import { type createBlockingInterval, type JsonCompatibleObject, type MaybePromise, type PartialWithUndefined, type SelectFrom } from '@augment-vir/common';
2
2
  import { type AnyDuration } from 'date-vir';
3
3
  import { type EmptyObject } from 'type-fest';
4
+ import { type CsrfTokenStore } from '../csrf-token-store.js';
4
5
  import { type CsrfHeaderNameOption } from '../csrf-token.js';
5
6
  /**
6
7
  * Config for {@link FrontendAuthClient}.
@@ -57,6 +58,7 @@ export type FrontendAuthClientConfig = Readonly<{
57
58
  allowedClockSkew: Readonly<AnyDuration>;
58
59
  overrides: PartialWithUndefined<{
59
60
  localStorage: Pick<Storage, 'setItem' | 'removeItem' | 'getItem'>;
61
+ csrfTokenStore: CsrfTokenStore;
60
62
  }>;
61
63
  }>;
62
64
  /**
@@ -78,7 +80,7 @@ export declare class FrontendAuthClient<AssumedUserParams extends JsonCompatible
78
80
  */
79
81
  destroy(): void;
80
82
  /** Wraps {@link getCurrentCsrfToken} to automatically handle wiping an invalid CSRF token. */
81
- getCurrentCsrfToken(): string | undefined;
83
+ getCurrentCsrfToken(): Promise<string | undefined>;
82
84
  /**
83
85
  * Assume the given user. Pass `undefined` to wipe the currently assumed user.
84
86
  *
@@ -93,7 +95,7 @@ export declare class FrontendAuthClient<AssumedUserParams extends JsonCompatible
93
95
  * `@augment-vir/common`](https://electrovir.github.io/augment-vir/functions/mergeDeep.html) to
94
96
  * combine them with these.
95
97
  */
96
- createAuthenticatedRequestInit(): RequestInit;
98
+ createAuthenticatedRequestInit(): Promise<RequestInit>;
97
99
  /** Wipes the current user auth. */
98
100
  logout(): Promise<void>;
99
101
  /**
@@ -1,6 +1,6 @@
1
1
  import { HttpStatus, } from '@augment-vir/common';
2
2
  import { listenToActivity } from 'detect-activity';
3
- import { 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
@@ -26,7 +26,9 @@ export class FrontendAuthClient {
26
26
  });
27
27
  }
28
28
  },
29
- debounce: config.checkUser.debounce || { minutes: 1 },
29
+ debounce: config.checkUser.debounce || {
30
+ minutes: 1,
31
+ },
30
32
  fireImmediately: false,
31
33
  });
32
34
  }
@@ -40,19 +42,13 @@ export class FrontendAuthClient {
40
42
  this.removeActivityListener?.();
41
43
  }
42
44
  /** Wraps {@link getCurrentCsrfToken} to automatically handle wiping an invalid CSRF token. */
43
- getCurrentCsrfToken() {
44
- const csrfTokenResult = getCurrentCsrfToken({
45
+ async getCurrentCsrfToken() {
46
+ const csrfTokenResult = await getCurrentCsrfToken({
45
47
  ...this.config.csrf,
46
- localStorage: this.config.overrides?.localStorage,
48
+ csrfTokenStore: this.config.overrides?.csrfTokenStore,
47
49
  allowedClockSkew: this.config.allowedClockSkew || defaultAllowedClockSkew,
48
50
  });
49
51
  if (csrfTokenResult.failure) {
50
- if (csrfTokenResult.failure !== CsrfTokenFailureReason.DoesNotExist) {
51
- wipeCurrentCsrfToken({
52
- ...this.config.csrf,
53
- localStorage: this.config.overrides?.localStorage,
54
- });
55
- }
56
52
  return undefined;
57
53
  }
58
54
  return csrfTokenResult.csrfToken.token;
@@ -69,7 +65,7 @@ export class FrontendAuthClient {
69
65
  localStorage.removeItem(storageKey);
70
66
  return true;
71
67
  }
72
- if (!(await this.config.canAssumeUser?.())) {
68
+ else if (!(await this.config.canAssumeUser?.())) {
73
69
  return false;
74
70
  }
75
71
  localStorage.setItem(storageKey, JSON.stringify(assumedUserParams));
@@ -94,8 +90,8 @@ export class FrontendAuthClient {
94
90
  * `@augment-vir/common`](https://electrovir.github.io/augment-vir/functions/mergeDeep.html) to
95
91
  * combine them with these.
96
92
  */
97
- createAuthenticatedRequestInit() {
98
- const csrfToken = this.getCurrentCsrfToken();
93
+ async createAuthenticatedRequestInit() {
94
+ const csrfToken = await this.getCurrentCsrfToken();
99
95
  const assumedUser = this.getAssumedUser();
100
96
  const headers = {
101
97
  ...(csrfToken
@@ -117,10 +113,6 @@ export class FrontendAuthClient {
117
113
  /** Wipes the current user auth. */
118
114
  async logout() {
119
115
  await this.config.authClearedCallback?.();
120
- wipeCurrentCsrfToken({
121
- ...this.config.csrf,
122
- localStorage: this.config.overrides?.localStorage,
123
- });
124
116
  }
125
117
  /**
126
118
  * Use to handle a login response. Automatically stores the CSRF token.
@@ -140,9 +132,9 @@ export class FrontendAuthClient {
140
132
  await this.logout();
141
133
  throw new Error('Did not receive any CSRF token.');
142
134
  }
143
- storeCsrfToken(csrfToken, {
135
+ await storeCsrfToken(csrfToken, {
144
136
  ...this.config.csrf,
145
- localStorage: this.config.overrides?.localStorage,
137
+ csrfTokenStore: this.config.overrides?.csrfTokenStore,
146
138
  });
147
139
  }
148
140
  /**
@@ -162,9 +154,9 @@ export class FrontendAuthClient {
162
154
  allowedClockSkew: this.config.allowedClockSkew || defaultAllowedClockSkew,
163
155
  });
164
156
  if (csrfToken) {
165
- storeCsrfToken(csrfToken, {
157
+ await storeCsrfToken(csrfToken, {
166
158
  ...this.config.csrf,
167
- localStorage: this.config.overrides?.localStorage,
159
+ csrfTokenStore: this.config.overrides?.csrfTokenStore,
168
160
  });
169
161
  }
170
162
  return true;
package/dist/auth.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type PartialWithUndefined } from '@augment-vir/common';
2
2
  import { type FullDate, type UtcTimezone } from 'date-vir';
3
3
  import { type CookieParams } from './cookie.js';
4
+ import { type CsrfTokenStore } from './csrf-token-store.js';
4
5
  import { type CsrfHeaderNameOption } from './csrf-token.js';
5
6
  import { type ParseJwtParams } from './jwt/jwt.js';
6
7
  import { type JwtUserData } from './jwt/user-jwt.js';
@@ -66,11 +67,11 @@ sessionStartedAt?: number | undefined): Promise<Record<string, string>>;
66
67
  *
67
68
  * @category Auth : Host
68
69
  */
69
- export declare function generateLogoutHeaders(cookieConfig: Readonly<Pick<CookieParams, 'cookieName' | 'hostOrigin' | 'isDev'>>, 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
  }
package/dist/cookie.js CHANGED
@@ -29,7 +29,9 @@ export async function generateAuthCookie(userJwtData, cookieConfig) {
29
29
  HttpOnly: true,
30
30
  Path: '/',
31
31
  SameSite: 'Strict',
32
- 'MAX-AGE': convertDuration(cookieConfig.cookieDuration, { seconds: true }).seconds,
32
+ 'MAX-AGE': convertDuration(cookieConfig.cookieDuration, {
33
+ seconds: true,
34
+ }).seconds,
33
35
  Secure: !cookieConfig.isDev,
34
36
  }),
35
37
  expiration,
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The interface used for overriding the default CSRF token store in storage functions.
3
+ *
4
+ * @category Internal
5
+ */
6
+ export type CsrfTokenStore = {
7
+ /** Retrieves the stored CSRF token, if any. */
8
+ getCsrfToken(): Promise<string | undefined>;
9
+ /** Stores a CSRF token. */
10
+ setCsrfToken(value: string): Promise<void>;
11
+ /** Deletes the stored CSRF token. */
12
+ deleteCsrfToken(): Promise<void>;
13
+ };
14
+ /**
15
+ * The default {@link LocalDbClient} instance used for storing CSRF tokens. This uses a dedicated
16
+ * store name to avoid collisions with other storage. Lazily initialized to avoid crashes in Node.js
17
+ * environments where IndexedDB is not available.
18
+ *
19
+ * @category Internal
20
+ */
21
+ export declare function getDefaultCsrfTokenStore(): Promise<CsrfTokenStore>;
@@ -0,0 +1,35 @@
1
+ import { LocalDbClient } from 'local-db-client';
2
+ import { defineShape } from 'object-shape-tester';
3
+ const csrfTokenDbShapes = {
4
+ csrfToken: defineShape(''),
5
+ };
6
+ async function createDefaultCsrfTokenStore() {
7
+ const client = await LocalDbClient.createClient(csrfTokenDbShapes, {
8
+ storeName: 'auth-vir-csrf',
9
+ });
10
+ return {
11
+ async getCsrfToken() {
12
+ return (await client.load.csrfToken()) || undefined;
13
+ },
14
+ async setCsrfToken(value) {
15
+ await client.set.csrfToken(value);
16
+ },
17
+ async deleteCsrfToken() {
18
+ await client.delete.csrfToken();
19
+ },
20
+ };
21
+ }
22
+ /**
23
+ * The default {@link LocalDbClient} instance used for storing CSRF tokens. This uses a dedicated
24
+ * store name to avoid collisions with other storage. Lazily initialized to avoid crashes in Node.js
25
+ * environments where IndexedDB is not available.
26
+ *
27
+ * @category Internal
28
+ */
29
+ export async function getDefaultCsrfTokenStore() {
30
+ if (!cachedStorePromise) {
31
+ cachedStorePromise = createDefaultCsrfTokenStore();
32
+ }
33
+ return cachedStorePromise;
34
+ }
35
+ let cachedStorePromise;