auth-vir 2.7.2 → 3.0.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/src/cookie.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  import {check} from '@augment-vir/assert';
2
- import {safeMatch, type PartialWithUndefined} from '@augment-vir/common';
3
- import {convertDuration, type AnyDuration} from 'date-vir';
2
+ import {escapeStringForRegExp, safeMatch, type PartialWithUndefined} from '@augment-vir/common';
3
+ import {
4
+ calculateRelativeDate,
5
+ convertDuration,
6
+ getNowInUtcTimezone,
7
+ type AnyDuration,
8
+ type FullDate,
9
+ type UtcTimezone,
10
+ } from 'date-vir';
4
11
  import {type Primitive} from 'type-fest';
5
12
  import {parseUrl} from 'url-vir';
6
13
  import {type CreateJwtParams, type ParseJwtParams, type ParsedJwt} from './jwt/jwt.js';
@@ -53,6 +60,16 @@ export type CookieParams = {
53
60
  isDev: boolean;
54
61
  }>;
55
62
 
63
+ /**
64
+ * Output from {@link generateAuthCookie}.
65
+ *
66
+ * @category Internal
67
+ */
68
+ export type GenerateAuthCookieResult = {
69
+ cookie: string;
70
+ expiration: FullDate<UtcTimezone>;
71
+ };
72
+
56
73
  /**
57
74
  * Generate a secure cookie that stores the user JWT data. Used in host (backend) code.
58
75
  *
@@ -61,19 +78,24 @@ export type CookieParams = {
61
78
  export async function generateAuthCookie(
62
79
  userJwtData: Readonly<JwtUserData>,
63
80
  cookieConfig: Readonly<CookieParams>,
64
- ): Promise<string> {
65
- return generateCookie({
66
- [cookieConfig.cookieName || 'auth']: await createUserJwt(
67
- userJwtData,
68
- cookieConfig.jwtParams,
69
- ),
70
- Domain: parseUrl(cookieConfig.hostOrigin).hostname,
71
- HttpOnly: true,
72
- Path: '/',
73
- SameSite: 'Strict',
74
- 'MAX-AGE': convertDuration(cookieConfig.cookieDuration, {seconds: true}).seconds,
75
- Secure: !cookieConfig.isDev,
76
- });
81
+ ): Promise<GenerateAuthCookieResult> {
82
+ const expiration = calculateRelativeDate(getNowInUtcTimezone(), cookieConfig.cookieDuration);
83
+
84
+ return {
85
+ cookie: generateCookie({
86
+ [cookieConfig.cookieName || 'auth']: await createUserJwt(
87
+ userJwtData,
88
+ cookieConfig.jwtParams,
89
+ ),
90
+ Domain: parseUrl(cookieConfig.hostOrigin).hostname,
91
+ HttpOnly: true,
92
+ Path: '/',
93
+ SameSite: 'Strict',
94
+ 'MAX-AGE': convertDuration(cookieConfig.cookieDuration, {seconds: true}).seconds,
95
+ Secure: !cookieConfig.isDev,
96
+ }),
97
+ expiration,
98
+ };
77
99
  }
78
100
 
79
101
  /**
@@ -136,7 +158,7 @@ export async function extractCookieJwt(
136
158
  jwtParams: Readonly<ParseJwtParams>,
137
159
  cookieName: string = AuthCookieName.Auth,
138
160
  ): Promise<undefined | ParsedJwt<JwtUserData>> {
139
- const cookieRegExp = new RegExp(`${cookieName}=[^;]+(?:;|$)`);
161
+ const cookieRegExp = new RegExp(`${escapeStringForRegExp(cookieName)}=[^;]+(?:;|$)`);
140
162
 
141
163
  const [cookieValue] = safeMatch(rawCookie, cookieRegExp);
142
164
 
package/src/csrf-token.ts CHANGED
@@ -13,8 +13,6 @@ 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 {AuthHeaderName} from './headers.js';
17
- import {authLog} from './log.js';
18
16
 
19
17
  /**
20
18
  * Shape definition for {@link CsrfToken}.
@@ -33,6 +31,15 @@ export const csrfTokenShape = defineShape({
33
31
  */
34
32
  export type CsrfToken = typeof csrfTokenShape.runtimeType;
35
33
 
34
+ /**
35
+ * Default allowed clock skew for CSRF token expiration checks. Accounts for differences between
36
+ * server and client clocks when checking token expiration.
37
+ *
38
+ * @category Internal
39
+ * @default {minutes: 5}
40
+ */
41
+ export const defaultAllowedClockSkew: Readonly<AnyDuration> = {minutes: 5};
42
+
36
43
  /**
37
44
  * Generates a random, cryptographically secure CSRF token.
38
45
  *
@@ -62,6 +69,37 @@ export enum CsrfTokenFailureReason {
62
69
  Expired = 'expired',
63
70
  }
64
71
 
72
+ /**
73
+ * Options for specifying the CSRF token header name.
74
+ *
75
+ * @category Auth : Client
76
+ * @category Auth : Host
77
+ */
78
+ export type CsrfHeaderNameOption = RequireExactlyOne<{
79
+ /** Prefix used to generate the header name: `${prefix}-auth-vir-csrf-token`. */
80
+ csrfHeaderPrefix: string;
81
+ /** Overrides the entire CSRF header name. */
82
+ csrfHeaderName: string;
83
+ }>;
84
+
85
+ /**
86
+ * Resolves a {@link CsrfHeaderNameOption} to the actual header name string.
87
+ *
88
+ * @category Auth : Client
89
+ * @category Auth : Host
90
+ */
91
+ export function resolveCsrfHeaderName(option: Readonly<CsrfHeaderNameOption>): string {
92
+ if ('csrfHeaderName' in option && option.csrfHeaderName) {
93
+ return option.csrfHeaderName;
94
+ } else {
95
+ return [
96
+ option.csrfHeaderPrefix,
97
+ 'auth-vir',
98
+ 'csrf-token',
99
+ ].join('-');
100
+ }
101
+ }
102
+
65
103
  /**
66
104
  * Output from {@link getCurrentCsrfToken}.
67
105
  *
@@ -79,15 +117,21 @@ export type GetCsrfTokenResult = RequireExactlyOne<{
79
117
  */
80
118
  export function extractCsrfTokenHeader(
81
119
  response: Readonly<PartialWithUndefined<SelectFrom<Response, {headers: true}>>>,
82
- overrides: PartialWithUndefined<{
83
- csrfHeaderName: string;
84
- }> = {},
120
+ csrfHeaderNameOption: Readonly<CsrfHeaderNameOption>,
121
+ options?: PartialWithUndefined<{
122
+ /**
123
+ * Allowed clock skew tolerance for CSRF token expiration checks.
124
+ *
125
+ * @default {minutes: 5}
126
+ */
127
+ allowedClockSkew: Readonly<AnyDuration>;
128
+ }>,
85
129
  ): Readonly<GetCsrfTokenResult> {
86
- const csrfTokenHeaderName = overrides.csrfHeaderName || AuthHeaderName.CsrfToken;
130
+ const csrfTokenHeaderName = resolveCsrfHeaderName(csrfHeaderNameOption);
87
131
 
88
132
  const rawCsrfToken = response.headers?.get(csrfTokenHeaderName);
89
133
 
90
- return parseCsrfToken(rawCsrfToken);
134
+ return parseCsrfToken(rawCsrfToken, options);
91
135
  }
92
136
 
93
137
  /**
@@ -97,19 +141,18 @@ export function extractCsrfTokenHeader(
97
141
  */
98
142
  export function storeCsrfToken(
99
143
  csrfToken: Readonly<CsrfToken>,
100
- overrides: PartialWithUndefined<{
101
- /**
102
- * Allows mocking or overriding the global `localStorage`.
103
- *
104
- * @default globalThis.localStorage
105
- */
106
- localStorage: Pick<Storage, 'setItem' | 'removeItem'>;
107
- /** Override the default CSRF token header name. */
108
- csrfHeaderName: string;
109
- }> = {},
144
+ options: Readonly<CsrfHeaderNameOption> &
145
+ PartialWithUndefined<{
146
+ /**
147
+ * Allows mocking or overriding the global `localStorage`.
148
+ *
149
+ * @default globalThis.localStorage
150
+ */
151
+ localStorage: Pick<Storage, 'setItem' | 'removeItem'>;
152
+ }>,
110
153
  ) {
111
- (overrides.localStorage || globalThis.localStorage).setItem(
112
- overrides.csrfHeaderName || AuthHeaderName.CsrfToken,
154
+ (options.localStorage || globalThis.localStorage).setItem(
155
+ resolveCsrfHeaderName(options),
113
156
  JSON.stringify(csrfToken),
114
157
  );
115
158
  }
@@ -119,7 +162,18 @@ export function storeCsrfToken(
119
162
  *
120
163
  * @category Internal
121
164
  */
122
- export function parseCsrfToken(value: string | undefined | null): Readonly<GetCsrfTokenResult> {
165
+ export function parseCsrfToken(
166
+ value: string | undefined | null,
167
+ options?: PartialWithUndefined<{
168
+ /**
169
+ * Allowed clock skew tolerance for CSRF token expiration checks. Accounts for differences
170
+ * between server and client clocks.
171
+ *
172
+ * @default {minutes: 5}
173
+ */
174
+ allowedClockSkew: Readonly<AnyDuration>;
175
+ }>,
176
+ ): Readonly<GetCsrfTokenResult> {
123
177
  if (!value) {
124
178
  return {
125
179
  failure: CsrfTokenFailureReason.DoesNotExist,
@@ -143,10 +197,15 @@ export function parseCsrfToken(value: string | undefined | null): Readonly<GetCs
143
197
  };
144
198
  }
145
199
 
200
+ const effectiveExpiration = calculateRelativeDate(
201
+ csrfToken.expiration,
202
+ options?.allowedClockSkew || defaultAllowedClockSkew,
203
+ );
204
+
146
205
  if (
147
206
  isDateAfter({
148
207
  fullDate: getNowInUtcTimezone(),
149
- relativeTo: csrfToken.expiration,
208
+ relativeTo: effectiveExpiration,
150
209
  })
151
210
  ) {
152
211
  return {
@@ -166,23 +225,27 @@ export function parseCsrfToken(value: string | undefined | null): Readonly<GetCs
166
225
  * @category Auth : Client
167
226
  */
168
227
  export function getCurrentCsrfToken(
169
- overrides: PartialWithUndefined<{
170
- /**
171
- * Allows mocking or overriding the global `localStorage`.
172
- *
173
- * @default globalThis.localStorage
174
- */
175
- localStorage: Pick<Storage, 'getItem'>;
176
- /** Override the default CSRF token header name. */
177
- csrfHeaderName: string;
178
- }> = {},
228
+ options: Readonly<CsrfHeaderNameOption> &
229
+ PartialWithUndefined<{
230
+ /**
231
+ * Allows mocking or overriding the global `localStorage`.
232
+ *
233
+ * @default globalThis.localStorage
234
+ */
235
+ localStorage: Pick<Storage, 'getItem'>;
236
+ /**
237
+ * Allowed clock skew tolerance for CSRF token expiration checks.
238
+ *
239
+ * @default {minutes: 5}
240
+ */
241
+ allowedClockSkew: Readonly<AnyDuration>;
242
+ }>,
179
243
  ): Readonly<GetCsrfTokenResult> {
180
244
  const rawCsrfToken: string | undefined =
181
- (overrides.localStorage || globalThis.localStorage).getItem(
182
- overrides.csrfHeaderName || AuthHeaderName.CsrfToken,
183
- ) || undefined;
245
+ (options.localStorage || globalThis.localStorage).getItem(resolveCsrfHeaderName(options)) ||
246
+ undefined;
184
247
 
185
- return parseCsrfToken(rawCsrfToken);
248
+ return parseCsrfToken(rawCsrfToken, options);
186
249
  }
187
250
 
188
251
  /**
@@ -192,19 +255,17 @@ export function getCurrentCsrfToken(
192
255
  * @category Auth : Client
193
256
  */
194
257
  export function wipeCurrentCsrfToken(
195
- overrides: PartialWithUndefined<{
196
- /**
197
- * Allows mocking or overriding the global `localStorage`.
198
- *
199
- * @default globalThis.localStorage
200
- */
201
- localStorage: Pick<Storage, 'removeItem'>;
202
- /** Override the default CSRF token header name. */
203
- csrfHeaderName: string;
204
- }> = {},
258
+ options: Readonly<CsrfHeaderNameOption> &
259
+ PartialWithUndefined<{
260
+ /**
261
+ * Allows mocking or overriding the global `localStorage`.
262
+ *
263
+ * @default globalThis.localStorage
264
+ */
265
+ localStorage: Pick<Storage, 'removeItem'>;
266
+ }>,
205
267
  ) {
206
- authLog('auth-vir: wipeCurrentCsrfToken called', new Error().stack);
207
- return (overrides.localStorage || globalThis.localStorage).removeItem(
208
- overrides.csrfHeaderName || AuthHeaderName.CsrfToken,
268
+ return (options.localStorage || globalThis.localStorage).removeItem(
269
+ resolveCsrfHeaderName(options),
209
270
  );
210
271
  }
package/src/hash.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import {assertWrap} from '@augment-vir/assert';
1
2
  import {
2
3
  type AnyObject,
3
4
  mergeDefinedProperties,
@@ -12,8 +13,8 @@ import {argon2id, argon2Verify, type IArgon2Options} from 'hash-wasm';
12
13
  */
13
14
  export const defaultHashOptions: HashPasswordOptions = {
14
15
  hashLength: 32,
15
- iterations: 256,
16
- memorySize: 512,
16
+ iterations: 2,
17
+ memorySize: 19_456,
17
18
  parallelism: 1,
18
19
  };
19
20
 
@@ -41,13 +42,15 @@ export async function hashPassword(
41
42
  ): Promise<string> {
42
43
  const salt = globalThis.crypto.getRandomValues(new Uint8Array(16));
43
44
 
44
- return await argon2id(
45
+ const hash = await argon2id(
45
46
  mergeDefinedProperties<AnyObject>(defaultHashOptions, options, {
46
47
  outputType: 'encoded',
47
48
  password: password.normalize(),
48
49
  salt,
49
50
  }) as IArgon2Options,
50
51
  );
52
+
53
+ return assertWrap.isTruthy(hash);
51
54
  }
52
55
 
53
56
  /**
@@ -76,6 +79,6 @@ export async function doesPasswordMatchHash({
76
79
  }): Promise<boolean> {
77
80
  return await argon2Verify({
78
81
  hash,
79
- password,
82
+ password: password.normalize(),
80
83
  });
81
84
  }
package/src/headers.ts CHANGED
@@ -6,7 +6,6 @@ import {check} from '@augment-vir/assert';
6
6
  * @category Internal
7
7
  */
8
8
  export enum AuthHeaderName {
9
- CsrfToken = 'csrf-token',
10
9
  AssumedUser = 'assumed-user',
11
10
  /**
12
11
  * Used to track if the current user is signed in only with a sign-up cookie, which prevents us
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './auth-client/backend-auth.client.js';
2
2
  export * from './auth-client/frontend-auth.client.js';
3
+ export * from './auth-client/is-session-refresh-ready.js';
3
4
  export * from './auth.js';
4
5
  export * from './cookie.js';
5
6
  export * from './csrf-token.js';
@@ -8,5 +9,4 @@ export * from './headers.js';
8
9
  export * from './jwt/jwt-keys.js';
9
10
  export * from './jwt/jwt.js';
10
11
  export * from './jwt/user-jwt.js';
11
- export * from './log.js';
12
12
  export * from './mock-local-storage.js';
package/src/jwt/jwt.ts CHANGED
@@ -3,6 +3,7 @@ import {type AnyObject, type PartialWithUndefined} from '@augment-vir/common';
3
3
  import {
4
4
  type AnyDuration,
5
5
  calculateRelativeDate,
6
+ convertDuration,
6
7
  createFullDateInUserTimezone,
7
8
  createUtcFullDate,
8
9
  type DateLike,
@@ -12,6 +13,7 @@ import {
12
13
  type UtcTimezone,
13
14
  } from 'date-vir';
14
15
  import {EncryptJWT, jwtDecrypt, jwtVerify, SignJWT} from 'jose';
16
+ import {defaultAllowedClockSkew} from '../csrf-token.js';
15
17
  import {type JwtKeys} from './jwt-keys.js';
16
18
 
17
19
  const encryptionProtectedHeader = {alg: 'dir', enc: 'A256GCM'};
@@ -137,7 +139,16 @@ export async function createJwt<JwtData extends AnyObject = AnyObject>(
137
139
  *
138
140
  * @category Internal
139
141
  */
140
- export type ParseJwtParams = Readonly<Pick<CreateJwtParams, 'issuer' | 'audience' | 'jwtKeys'>>;
142
+ export type ParseJwtParams = Readonly<Pick<CreateJwtParams, 'issuer' | 'audience' | 'jwtKeys'>> &
143
+ PartialWithUndefined<{
144
+ /**
145
+ * Allowed clock skew tolerance for JWT expiration and timestamp checks. Accounts for
146
+ * differences between server and client clocks.
147
+ *
148
+ * @default {minutes: 5}
149
+ */
150
+ allowedClockSkew: Readonly<AnyDuration>;
151
+ }>;
141
152
 
142
153
  /**
143
154
  * A fully parsed JWT with embedded data.
@@ -147,6 +158,8 @@ export type ParseJwtParams = Readonly<Pick<CreateJwtParams, 'issuer' | 'audience
147
158
  export type ParsedJwt<JwtData extends AnyObject> = {
148
159
  data: JwtData;
149
160
  jwtExpiration: FullDate<UtcTimezone>;
161
+ /** When the JWT was issued (`iat` claim). */
162
+ jwtIssuedAt: FullDate<UtcTimezone>;
150
163
  };
151
164
 
152
165
  /**
@@ -167,6 +180,11 @@ export async function parseJwt<JwtData extends AnyObject = AnyObject>(
167
180
  throw new TypeError('Decrypted jwt is not a string.');
168
181
  }
169
182
 
183
+ const clockToleranceSeconds = convertDuration(
184
+ params.allowedClockSkew || defaultAllowedClockSkew,
185
+ {seconds: true},
186
+ ).seconds;
187
+
170
188
  const verifiedJwt = await jwtVerify(decryptedJwt.payload.jwt, params.jwtKeys.signingKey, {
171
189
  issuer: params.issuer,
172
190
  audience: params.audience,
@@ -175,9 +193,13 @@ export async function parseJwt<JwtData extends AnyObject = AnyObject>(
175
193
  'aud',
176
194
  'iss',
177
195
  ],
196
+ clockTolerance: clockToleranceSeconds,
178
197
  });
179
198
 
180
- if (!verifiedJwt.payload.iat || verifiedJwt.payload.iat * 1000 > Date.now()) {
199
+ if (
200
+ !verifiedJwt.payload.iat ||
201
+ verifiedJwt.payload.iat * 1000 > Date.now() + clockToleranceSeconds * 1000
202
+ ) {
181
203
  throw new Error('"iat" claim timestamp check failed');
182
204
  }
183
205
 
@@ -187,15 +209,18 @@ export async function parseJwt<JwtData extends AnyObject = AnyObject>(
187
209
  throw new Error('Invalid signing protected header.');
188
210
  }
189
211
 
212
+ const issuedAtSeconds = assertWrap.isDefined(verifiedJwt.payload.iat, 'JWT has no issued at.');
190
213
  const expirationSeconds = assertWrap.isDefined(
191
214
  verifiedJwt.payload.exp,
192
215
  'JWT has no expiration.',
193
216
  );
194
217
 
218
+ const jwtIssuedAt: FullDate<UtcTimezone> = parseJwtTimestamp(issuedAtSeconds);
195
219
  const jwtExpiration: FullDate<UtcTimezone> = parseJwtTimestamp(expirationSeconds);
196
220
 
197
221
  return {
198
222
  data: data as JwtData,
199
223
  jwtExpiration,
224
+ jwtIssuedAt,
200
225
  };
201
226
  }
@@ -62,7 +62,7 @@ export async function parseUserJwt(
62
62
  encryptedJwt: string,
63
63
  params: Readonly<ParseJwtParams>,
64
64
  ): Promise<ParsedJwt<JwtUserData> | undefined> {
65
- const {data, jwtExpiration} = await parseJwt(encryptedJwt, params);
65
+ const {data, jwtExpiration, jwtIssuedAt} = await parseJwt(encryptedJwt, params);
66
66
 
67
67
  if (!checkValidShape(data, userJwtDataShape)) {
68
68
  throw new TypeError('Verified jwt has wrong data.');
@@ -71,5 +71,6 @@ export async function parseUserJwt(
71
71
  return {
72
72
  data,
73
73
  jwtExpiration,
74
+ jwtIssuedAt,
74
75
  };
75
76
  }
package/dist/log.d.ts DELETED
@@ -1,12 +0,0 @@
1
- /**
2
- * Send logs to the console for debugging.
3
- *
4
- * @category Internal
5
- */
6
- export declare function authLog(...params: any[]): void;
7
- /**
8
- * Set to `false` to disable logging.
9
- *
10
- * @category Internal
11
- */
12
- export declare function setShouldLogAuth(value: boolean): void;
package/dist/log.js DELETED
@@ -1,20 +0,0 @@
1
- /**
2
- * Send logs to the console for debugging.
3
- *
4
- * @category Internal
5
- */
6
- export function authLog(...params) {
7
- if (!shouldLogAuth) {
8
- return;
9
- }
10
- console.info(...params);
11
- }
12
- let shouldLogAuth = true;
13
- /**
14
- * Set to `false` to disable logging.
15
- *
16
- * @category Internal
17
- */
18
- export function setShouldLogAuth(value) {
19
- shouldLogAuth = value;
20
- }
package/src/log.ts DELETED
@@ -1,22 +0,0 @@
1
- /**
2
- * Send logs to the console for debugging.
3
- *
4
- * @category Internal
5
- */
6
- export function authLog(...params: any[]) {
7
- if (!shouldLogAuth) {
8
- return;
9
- }
10
- console.info(...params);
11
- }
12
-
13
- let shouldLogAuth = true;
14
-
15
- /**
16
- * Set to `false` to disable logging.
17
- *
18
- * @category Internal
19
- */
20
- export function setShouldLogAuth(value: boolean) {
21
- shouldLogAuth = value;
22
- }