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.
@@ -1,8 +1,6 @@
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 { AuthHeaderName } from './headers.js';
5
- import { authLog } from './log.js';
6
4
  /**
7
5
  * Shape definition for {@link CsrfToken}.
8
6
  *
@@ -12,6 +10,14 @@ export const csrfTokenShape = defineShape({
12
10
  token: '',
13
11
  expiration: fullDateShape,
14
12
  });
13
+ /**
14
+ * Default allowed clock skew for CSRF token expiration checks. Accounts for differences between
15
+ * server and client clocks when checking token expiration.
16
+ *
17
+ * @category Internal
18
+ * @default {minutes: 5}
19
+ */
20
+ export const defaultAllowedClockSkew = { minutes: 5 };
15
21
  /**
16
22
  * Generates a random, cryptographically secure CSRF token.
17
23
  *
@@ -39,30 +45,48 @@ export var CsrfTokenFailureReason;
39
45
  /** A CSRF token was found and parsed but is expired. */
40
46
  CsrfTokenFailureReason["Expired"] = "expired";
41
47
  })(CsrfTokenFailureReason || (CsrfTokenFailureReason = {}));
48
+ /**
49
+ * Resolves a {@link CsrfHeaderNameOption} to the actual header name string.
50
+ *
51
+ * @category Auth : Client
52
+ * @category Auth : Host
53
+ */
54
+ export function resolveCsrfHeaderName(option) {
55
+ if ('csrfHeaderName' in option && option.csrfHeaderName) {
56
+ return option.csrfHeaderName;
57
+ }
58
+ else {
59
+ return [
60
+ option.csrfHeaderPrefix,
61
+ 'auth-vir',
62
+ 'csrf-token',
63
+ ].join('-');
64
+ }
65
+ }
42
66
  /**
43
67
  * Extract the CSRF token header from a response.
44
68
  *
45
69
  * @category Auth : Client
46
70
  */
47
- export function extractCsrfTokenHeader(response, overrides = {}) {
48
- const csrfTokenHeaderName = overrides.csrfHeaderName || AuthHeaderName.CsrfToken;
71
+ export function extractCsrfTokenHeader(response, csrfHeaderNameOption, options) {
72
+ const csrfTokenHeaderName = resolveCsrfHeaderName(csrfHeaderNameOption);
49
73
  const rawCsrfToken = response.headers?.get(csrfTokenHeaderName);
50
- return parseCsrfToken(rawCsrfToken);
74
+ return parseCsrfToken(rawCsrfToken, options);
51
75
  }
52
76
  /**
53
77
  * Stores the given CSRF token into local storage.
54
78
  *
55
79
  * @category Auth : Client
56
80
  */
57
- export function storeCsrfToken(csrfToken, overrides = {}) {
58
- (overrides.localStorage || globalThis.localStorage).setItem(overrides.csrfHeaderName || AuthHeaderName.CsrfToken, JSON.stringify(csrfToken));
81
+ export function storeCsrfToken(csrfToken, options) {
82
+ (options.localStorage || globalThis.localStorage).setItem(resolveCsrfHeaderName(options), JSON.stringify(csrfToken));
59
83
  }
60
84
  /**
61
85
  * Parse a raw CSRF token JSON string.
62
86
  *
63
87
  * @category Internal
64
88
  */
65
- export function parseCsrfToken(value) {
89
+ export function parseCsrfToken(value, options) {
66
90
  if (!value) {
67
91
  return {
68
92
  failure: CsrfTokenFailureReason.DoesNotExist,
@@ -79,9 +103,10 @@ export function parseCsrfToken(value) {
79
103
  failure: CsrfTokenFailureReason.ParseFailed,
80
104
  };
81
105
  }
106
+ const effectiveExpiration = calculateRelativeDate(csrfToken.expiration, options?.allowedClockSkew || defaultAllowedClockSkew);
82
107
  if (isDateAfter({
83
108
  fullDate: getNowInUtcTimezone(),
84
- relativeTo: csrfToken.expiration,
109
+ relativeTo: effectiveExpiration,
85
110
  })) {
86
111
  return {
87
112
  failure: CsrfTokenFailureReason.Expired,
@@ -97,9 +122,10 @@ export function parseCsrfToken(value) {
97
122
  *
98
123
  * @category Auth : Client
99
124
  */
100
- export function getCurrentCsrfToken(overrides = {}) {
101
- const rawCsrfToken = (overrides.localStorage || globalThis.localStorage).getItem(overrides.csrfHeaderName || AuthHeaderName.CsrfToken) || undefined;
102
- return parseCsrfToken(rawCsrfToken);
125
+ export function getCurrentCsrfToken(options) {
126
+ const rawCsrfToken = (options.localStorage || globalThis.localStorage).getItem(resolveCsrfHeaderName(options)) ||
127
+ undefined;
128
+ return parseCsrfToken(rawCsrfToken, options);
103
129
  }
104
130
  /**
105
131
  * Wipes the current stored CSRF token. This should be used by client (frontend) code to logout a
@@ -107,7 +133,6 @@ export function getCurrentCsrfToken(overrides = {}) {
107
133
  *
108
134
  * @category Auth : Client
109
135
  */
110
- export function wipeCurrentCsrfToken(overrides = {}) {
111
- authLog('auth-vir: wipeCurrentCsrfToken called', new Error().stack);
112
- return (overrides.localStorage || globalThis.localStorage).removeItem(overrides.csrfHeaderName || AuthHeaderName.CsrfToken);
136
+ export function wipeCurrentCsrfToken(options) {
137
+ return (options.localStorage || globalThis.localStorage).removeItem(resolveCsrfHeaderName(options));
113
138
  }
package/dist/hash.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { assertWrap } from '@augment-vir/assert';
1
2
  import { mergeDefinedProperties, } from '@augment-vir/common';
2
3
  import { argon2id, argon2Verify } from 'hash-wasm';
3
4
  /**
@@ -7,8 +8,8 @@ import { argon2id, argon2Verify } from 'hash-wasm';
7
8
  */
8
9
  export const defaultHashOptions = {
9
10
  hashLength: 32,
10
- iterations: 256,
11
- memorySize: 512,
11
+ iterations: 2,
12
+ memorySize: 19_456,
12
13
  parallelism: 1,
13
14
  };
14
15
  /**
@@ -22,11 +23,12 @@ export const defaultHashOptions = {
22
23
  */
23
24
  export async function hashPassword(password, options = {}) {
24
25
  const salt = globalThis.crypto.getRandomValues(new Uint8Array(16));
25
- return await argon2id(mergeDefinedProperties(defaultHashOptions, options, {
26
+ const hash = await argon2id(mergeDefinedProperties(defaultHashOptions, options, {
26
27
  outputType: 'encoded',
27
28
  password: password.normalize(),
28
29
  salt,
29
30
  }));
31
+ return assertWrap.isTruthy(hash);
30
32
  }
31
33
  /**
32
34
  * A utility that provides more accurate string byte size than doing `string.length`.
@@ -45,6 +47,6 @@ export function getByteLength(input) {
45
47
  export async function doesPasswordMatchHash({ password, hash, }) {
46
48
  return await argon2Verify({
47
49
  hash,
48
- password,
50
+ password: password.normalize(),
49
51
  });
50
52
  }
package/dist/headers.d.ts CHANGED
@@ -4,7 +4,6 @@
4
4
  * @category Internal
5
5
  */
6
6
  export declare enum AuthHeaderName {
7
- CsrfToken = "csrf-token",
8
7
  AssumedUser = "assumed-user",
9
8
  /**
10
9
  * Used to track if the current user is signed in only with a sign-up cookie, which prevents us
package/dist/headers.js CHANGED
@@ -6,7 +6,6 @@ import { check } from '@augment-vir/assert';
6
6
  */
7
7
  export var AuthHeaderName;
8
8
  (function (AuthHeaderName) {
9
- AuthHeaderName["CsrfToken"] = "csrf-token";
10
9
  AuthHeaderName["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/dist/index.d.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/dist/index.js 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/dist/jwt/jwt.d.ts CHANGED
@@ -85,7 +85,15 @@ data: JwtData, params: Readonly<CreateJwtParams>): Promise<string>;
85
85
  *
86
86
  * @category Internal
87
87
  */
88
- export type ParseJwtParams = Readonly<Pick<CreateJwtParams, 'issuer' | 'audience' | 'jwtKeys'>>;
88
+ export type ParseJwtParams = Readonly<Pick<CreateJwtParams, 'issuer' | 'audience' | 'jwtKeys'>> & PartialWithUndefined<{
89
+ /**
90
+ * Allowed clock skew tolerance for JWT expiration and timestamp checks. Accounts for
91
+ * differences between server and client clocks.
92
+ *
93
+ * @default {minutes: 5}
94
+ */
95
+ allowedClockSkew: Readonly<AnyDuration>;
96
+ }>;
89
97
  /**
90
98
  * A fully parsed JWT with embedded data.
91
99
  *
@@ -94,6 +102,8 @@ export type ParseJwtParams = Readonly<Pick<CreateJwtParams, 'issuer' | 'audience
94
102
  export type ParsedJwt<JwtData extends AnyObject> = {
95
103
  data: JwtData;
96
104
  jwtExpiration: FullDate<UtcTimezone>;
105
+ /** When the JWT was issued (`iat` claim). */
106
+ jwtIssuedAt: FullDate<UtcTimezone>;
97
107
  };
98
108
  /**
99
109
  * Parse and extract all data from an encrypted and signed JWT.
package/dist/jwt/jwt.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { assertWrap, check } from '@augment-vir/assert';
2
- import { calculateRelativeDate, createFullDateInUserTimezone, createUtcFullDate, getNowInUtcTimezone, toTimestamp, } from 'date-vir';
2
+ import { calculateRelativeDate, convertDuration, createFullDateInUserTimezone, createUtcFullDate, getNowInUtcTimezone, toTimestamp, } from 'date-vir';
3
3
  import { EncryptJWT, jwtDecrypt, jwtVerify, SignJWT } from 'jose';
4
+ import { defaultAllowedClockSkew } from '../csrf-token.js';
4
5
  const encryptionProtectedHeader = { alg: 'dir', enc: 'A256GCM' };
5
6
  const signingProtectedHeader = { alg: 'HS512' };
6
7
  /**
@@ -57,6 +58,7 @@ export async function parseJwt(encryptedJwt, params) {
57
58
  else if (!check.isString(decryptedJwt.payload.jwt)) {
58
59
  throw new TypeError('Decrypted jwt is not a string.');
59
60
  }
61
+ const clockToleranceSeconds = convertDuration(params.allowedClockSkew || defaultAllowedClockSkew, { seconds: true }).seconds;
60
62
  const verifiedJwt = await jwtVerify(decryptedJwt.payload.jwt, params.jwtKeys.signingKey, {
61
63
  issuer: params.issuer,
62
64
  audience: params.audience,
@@ -65,18 +67,23 @@ export async function parseJwt(encryptedJwt, params) {
65
67
  'aud',
66
68
  'iss',
67
69
  ],
70
+ clockTolerance: clockToleranceSeconds,
68
71
  });
69
- if (!verifiedJwt.payload.iat || verifiedJwt.payload.iat * 1000 > Date.now()) {
72
+ if (!verifiedJwt.payload.iat ||
73
+ verifiedJwt.payload.iat * 1000 > Date.now() + clockToleranceSeconds * 1000) {
70
74
  throw new Error('"iat" claim timestamp check failed');
71
75
  }
72
76
  const data = verifiedJwt.payload.data;
73
77
  if (!check.deepEquals(verifiedJwt.protectedHeader, signingProtectedHeader)) {
74
78
  throw new Error('Invalid signing protected header.');
75
79
  }
80
+ const issuedAtSeconds = assertWrap.isDefined(verifiedJwt.payload.iat, 'JWT has no issued at.');
76
81
  const expirationSeconds = assertWrap.isDefined(verifiedJwt.payload.exp, 'JWT has no expiration.');
82
+ const jwtIssuedAt = parseJwtTimestamp(issuedAtSeconds);
77
83
  const jwtExpiration = parseJwtTimestamp(expirationSeconds);
78
84
  return {
79
85
  data: data,
80
86
  jwtExpiration,
87
+ jwtIssuedAt,
81
88
  };
82
89
  }
@@ -39,12 +39,13 @@ export async function createUserJwt(data, params) {
39
39
  * @category Internal
40
40
  */
41
41
  export async function parseUserJwt(encryptedJwt, params) {
42
- const { data, jwtExpiration } = await parseJwt(encryptedJwt, params);
42
+ const { data, jwtExpiration, jwtIssuedAt } = await parseJwt(encryptedJwt, params);
43
43
  if (!checkValidShape(data, userJwtDataShape)) {
44
44
  throw new TypeError('Verified jwt has wrong data.');
45
45
  }
46
46
  return {
47
47
  data,
48
48
  jwtExpiration,
49
+ jwtIssuedAt,
49
50
  };
50
51
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auth-vir",
3
- "version": "2.7.2",
3
+ "version": "3.0.1",
4
4
  "description": "Auth made easy and secure via JWT cookies, CSRF tokens, and password hashing helpers.",
5
5
  "keywords": [
6
6
  "auth",
@@ -34,7 +34,6 @@
34
34
  "compile": "virmator compile",
35
35
  "docs": "virmator docs",
36
36
  "init": "node -e \"require('fs').rmSync('src/generated', { recursive: true, force: true })\" && prisma generate --no-hints --schema test-files/schema.prisma",
37
- "start": "virmator frontend",
38
37
  "test": "runstorm --names web,node, \"npm run test:web\" \"npm run test:node\"",
39
38
  "test:docs": "virmator docs check",
40
39
  "test:node": "virmator test node 'src/**/*.test.node.ts'",
@@ -42,21 +41,21 @@
42
41
  "test:web": "virmator test web"
43
42
  },
44
43
  "dependencies": {
45
- "@augment-vir/assert": "^31.59.2",
46
- "@augment-vir/common": "^31.59.2",
47
- "date-vir": "^8.1.0",
48
- "detect-activity": "^0.0.1",
44
+ "@augment-vir/assert": "^31.65.0",
45
+ "@augment-vir/common": "^31.65.0",
46
+ "date-vir": "^8.1.1",
47
+ "detect-activity": "^1.0.0",
49
48
  "hash-wasm": "^4.12.0",
50
49
  "jose": "^6.1.3",
51
50
  "object-shape-tester": "^6.11.0",
52
- "type-fest": "^5.4.1",
51
+ "type-fest": "^5.4.4",
53
52
  "url-vir": "^2.1.7"
54
53
  },
55
54
  "devDependencies": {
56
- "@augment-vir/test": "^31.59.2",
55
+ "@augment-vir/test": "^31.65.0",
57
56
  "@prisma/client": "^6.19.2",
58
- "@types/node": "^24.9.1",
59
- "@web/dev-server-esbuild": "^1.0.4",
57
+ "@types/node": "^25.3.0",
58
+ "@web/dev-server-esbuild": "^1.0.5",
60
59
  "@web/test-runner": "^0.20.2",
61
60
  "@web/test-runner-commands": "^0.9.0",
62
61
  "@web/test-runner-playwright": "^0.11.1",
@@ -64,7 +63,8 @@
64
63
  "istanbul-smart-text-reporter": "^1.1.5",
65
64
  "markdown-code-example-inserter": "^3.0.3",
66
65
  "prisma-vir": "^2.3.3",
67
- "typedoc": "^0.28.16"
66
+ "typedoc": "^0.28.17",
67
+ "typescript": "^5.9.3"
68
68
  },
69
69
  "engines": {
70
70
  "node": ">=22"