auth-vir 2.5.0 → 2.6.0

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.
@@ -103,12 +103,18 @@ export type BackendAuthClientConfig<DatabaseUser extends AnyObject, UserId exten
103
103
  */
104
104
  userSessionIdleTimeout: Readonly<AnyDuration>;
105
105
  /**
106
- * How long before a user's session times out when we should start trying to refresh their
107
- * session.
106
+ * How long into a user's session when we should start trying to refresh their session.
108
107
  *
109
- * @default {minutes: 10}
108
+ * @default {minutes: 2}
110
109
  */
111
- sessionRefreshThreshold: Readonly<AnyDuration>;
110
+ sessionRefreshTimeout: Readonly<AnyDuration>;
111
+ /**
112
+ * The maximum duration a session can last, regardless of activity. After this time, the
113
+ * user will be logged out even if they are actively using the application.
114
+ *
115
+ * @default {weeks: 2}
116
+ */
117
+ maxSessionDuration: Readonly<AnyDuration>;
112
118
  overrides: PartialWithUndefined<{
113
119
  csrfHeaderName: CsrfHeaderName;
114
120
  assumedUserHeaderName: string;
@@ -1,5 +1,5 @@
1
1
  import { ensureArray, } from '@augment-vir/common';
2
- import { calculateRelativeDate, getNowInUtcTimezone, isDateAfter } from 'date-vir';
2
+ import { calculateRelativeDate, createUtcFullDate, getNowInUtcTimezone, isDateAfter, negateDuration, } from 'date-vir';
3
3
  import { extractUserIdFromRequestHeaders, generateLogoutHeaders, generateSuccessfulLoginHeaders, insecureExtractUserIdFromCookieAlone, } from '../auth.js';
4
4
  import { AuthCookieName } from '../cookie.js';
5
5
  import { AuthHeaderName, mergeHeaderValues } from '../headers.js';
@@ -7,8 +7,11 @@ import { parseJwtKeys } from '../jwt/jwt-keys.js';
7
7
  const defaultSessionIdleTimeout = {
8
8
  minutes: 20,
9
9
  };
10
- const defaultSessionRefreshThreshold = {
11
- minutes: 10,
10
+ const defaultSessionRefreshTimeout = {
11
+ minutes: 2,
12
+ };
13
+ const defaultMaxSessionDuration = {
14
+ weeks: 2,
12
15
  };
13
16
  /**
14
17
  * An auth client for creating and validating JWTs embedded in cookies. This should only be used in
@@ -62,6 +65,22 @@ export class BackendAuthClient {
62
65
  if (isExpiredAlready) {
63
66
  return undefined;
64
67
  }
68
+ /**
69
+ * Check if the session has exceeded the max session duration. If so, don't refresh the
70
+ * session and let it expire naturally.
71
+ */
72
+ const maxSessionDuration = this.config.maxSessionDuration || defaultMaxSessionDuration;
73
+ if (userIdResult.sessionStartedAt) {
74
+ const sessionStartDate = createUtcFullDate(userIdResult.sessionStartedAt);
75
+ const maxSessionEndDate = calculateRelativeDate(sessionStartDate, maxSessionDuration);
76
+ const isSessionExpired = isDateAfter({
77
+ fullDate: now,
78
+ relativeTo: maxSessionEndDate,
79
+ });
80
+ if (isSessionExpired) {
81
+ return undefined;
82
+ }
83
+ }
65
84
  /**
66
85
  * This check performs the following: the current time + the refresh threshold > JWT
67
86
  * expiration.
@@ -77,9 +96,10 @@ export class BackendAuthClient {
77
96
  * - Y = JWT expiration within the refresh threshold: {@link isRefreshReady} = true.
78
97
  * - Z = JWT expiration outside the refresh threshold: {@link isRefreshReady} = false.
79
98
  */
99
+ const sessionRefreshTimeout = this.config.sessionRefreshTimeout || defaultSessionRefreshTimeout;
80
100
  const isRefreshReady = isDateAfter({
81
- fullDate: calculateRelativeDate(now, this.config.sessionRefreshThreshold || defaultSessionRefreshThreshold),
82
- relativeTo: userIdResult.jwtExpiration,
101
+ fullDate: now,
102
+ relativeTo: calculateRelativeDate(userIdResult.jwtExpiration, negateDuration(sessionRefreshTimeout)),
83
103
  });
84
104
  if (isRefreshReady) {
85
105
  return this.createLoginHeaders({
@@ -195,10 +215,12 @@ export class BackendAuthClient {
195
215
  requestHeaders,
196
216
  }), this.config.overrides)
197
217
  : undefined;
218
+ const existingUserIdResult = await extractUserIdFromRequestHeaders(requestHeaders, await this.getJwtParams(), isSignUpCookie ? AuthCookieName.SignUp : AuthCookieName.Auth, this.config.overrides);
219
+ const sessionStartedAt = existingUserIdResult?.sessionStartedAt;
198
220
  const newCookieHeaders = await generateSuccessfulLoginHeaders(userId, await this.getCookieParams({
199
221
  isSignUpCookie,
200
222
  requestHeaders,
201
- }), this.config.overrides);
223
+ }), this.config.overrides, sessionStartedAt);
202
224
  return {
203
225
  ...newCookieHeaders,
204
226
  'set-cookie': mergeHeaderValues(newCookieHeaders['set-cookie'], discardOppositeCookieHeaders?.['set-cookie']),
@@ -56,6 +56,8 @@ export type FrontendAuthClientConfig = PartialWithUndefined<{
56
56
  export declare class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject = EmptyObject> {
57
57
  protected readonly config: FrontendAuthClientConfig;
58
58
  protected userCheckInterval: undefined | ReturnType<typeof createBlockingInterval>;
59
+ /** Used to clean up the activity listener on `.destroy()`. */
60
+ protected removeActivityListener: VoidFunction | undefined;
59
61
  constructor(config?: FrontendAuthClientConfig);
60
62
  /**
61
63
  * Destroys the client and performs all necessary cleanup (like clearing the user check
@@ -12,10 +12,12 @@ import { AuthHeaderName } from '../headers.js';
12
12
  export class FrontendAuthClient {
13
13
  config;
14
14
  userCheckInterval;
15
+ /** Used to clean up the activity listener on `.destroy()`. */
16
+ removeActivityListener;
15
17
  constructor(config = {}) {
16
18
  this.config = config;
17
19
  if (config.checkUser) {
18
- listenToActivity({
20
+ this.removeActivityListener = listenToActivity({
19
21
  listener: async () => {
20
22
  const response = await config.checkUser?.performCheck();
21
23
  if (response) {
@@ -35,6 +37,7 @@ export class FrontendAuthClient {
35
37
  */
36
38
  destroy() {
37
39
  this.userCheckInterval?.clearInterval();
40
+ this.removeActivityListener?.();
38
41
  }
39
42
  /** Wraps {@link getCurrentCsrfToken} to automatically handle wiping an invalid CSRF token. */
40
43
  async getCurrentCsrfToken() {
package/dist/auth.d.ts CHANGED
@@ -3,6 +3,7 @@ import { type FullDate, type UtcTimezone } from 'date-vir';
3
3
  import { type CookieParams } from './cookie.js';
4
4
  import { AuthHeaderName } from './headers.js';
5
5
  import { type ParseJwtParams } from './jwt/jwt.js';
6
+ import { type JwtUserData } from './jwt/user-jwt.js';
6
7
  /**
7
8
  * All possible headers container types supported by {@link extractUserIdFromRequestHeaders}.
8
9
  *
@@ -18,6 +19,11 @@ export type UserIdResult<UserId extends string | number> = {
18
19
  userId: UserId;
19
20
  jwtExpiration: FullDate<UtcTimezone>;
20
21
  cookieName: string;
22
+ /**
23
+ * Unix timestamp (in milliseconds) when the session was originally started. Used to enforce max
24
+ * session duration.
25
+ */
26
+ sessionStartedAt: JwtUserData['sessionStartedAt'];
21
27
  };
22
28
  /**
23
29
  * Extract the user id from a request by checking both the request cookie and CSRF token. This is
@@ -48,7 +54,12 @@ export declare function generateSuccessfulLoginHeaders<CsrfHeaderName extends st
48
54
  /** The id from your database of the user you're authenticating. */
49
55
  userId: string | number, cookieConfig: Readonly<CookieParams>, overrides?: PartialWithUndefined<{
50
56
  csrfHeaderName: CsrfHeaderName;
51
- }>): Promise<{
57
+ }>,
58
+ /**
59
+ * The timestamp (in seconds) when the session originally started. If not provided, the current
60
+ * time will be used (for new sessions).
61
+ */
62
+ sessionStartedAt?: number | undefined): Promise<{
52
63
  'set-cookie': string;
53
64
  } & Record<CsrfHeaderName, string>>;
54
65
  /**
package/dist/auth.js CHANGED
@@ -48,6 +48,7 @@ export async function extractUserIdFromRequestHeaders(headers, jwtParams, cookie
48
48
  userId: jwt.data.userId,
49
49
  jwtExpiration: jwt.jwtExpiration,
50
50
  cookieName,
51
+ sessionStartedAt: jwt.data.sessionStartedAt,
51
52
  };
52
53
  }
53
54
  catch {
@@ -76,6 +77,7 @@ export async function insecureExtractUserIdFromCookieAlone(headers, jwtParams, c
76
77
  userId: jwt.data.userId,
77
78
  jwtExpiration: jwt.jwtExpiration,
78
79
  cookieName,
80
+ sessionStartedAt: jwt.data.sessionStartedAt,
79
81
  };
80
82
  }
81
83
  catch {
@@ -89,13 +91,19 @@ export async function insecureExtractUserIdFromCookieAlone(headers, jwtParams, c
89
91
  */
90
92
  export async function generateSuccessfulLoginHeaders(
91
93
  /** The id from your database of the user you're authenticating. */
92
- userId, cookieConfig, overrides = {}) {
94
+ userId, cookieConfig, overrides = {},
95
+ /**
96
+ * The timestamp (in seconds) when the session originally started. If not provided, the current
97
+ * time will be used (for new sessions).
98
+ */
99
+ sessionStartedAt) {
93
100
  const csrfToken = generateCsrfToken(cookieConfig.cookieDuration);
94
101
  const csrfHeaderName = (overrides.csrfHeaderName || AuthHeaderName.CsrfToken);
95
102
  return {
96
103
  'set-cookie': await generateAuthCookie({
97
104
  csrfToken: csrfToken.token,
98
105
  userId,
106
+ sessionStartedAt: sessionStartedAt ?? Date.now(),
99
107
  }, cookieConfig),
100
108
  [csrfHeaderName]: JSON.stringify(csrfToken),
101
109
  };
@@ -1,5 +1,6 @@
1
1
  /* !!! This is code generated by Prisma. Do not edit directly. !!! */
2
2
  /* eslint-disable */
3
+ // biome-ignore-all lint: generated file
3
4
  // @ts-nocheck
4
5
  /*
5
6
  * This file should be your main import to use Prisma-related types and utilities in a browser.
@@ -1,5 +1,6 @@
1
1
  /* !!! This is code generated by Prisma. Do not edit directly. !!! */
2
2
  /* eslint-disable */
3
+ // biome-ignore-all lint: generated file
3
4
  // @ts-nocheck
4
5
  /*
5
6
  * This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
@@ -1,5 +1,6 @@
1
1
  /* !!! This is code generated by Prisma. Do not edit directly. !!! */
2
2
  /* eslint-disable */
3
+ // biome-ignore-all lint: generated file
3
4
  // @ts-nocheck
4
5
  /*
5
6
  * This file exports all enum related types from the schema.
@@ -1,5 +1,6 @@
1
1
  /* !!! This is code generated by Prisma. Do not edit directly. !!! */
2
2
  /* eslint-disable */
3
+ // biome-ignore-all lint: generated file
3
4
  // @ts-nocheck
4
5
  /*
5
6
  * WARNING: This is an internal file that is subject to change!
@@ -21,8 +22,8 @@ const config = {
21
22
  "fromEnvVar": null
22
23
  },
23
24
  "config": {
24
- "moduleFormat": "esm",
25
- "engineType": "client"
25
+ "engineType": "client",
26
+ "moduleFormat": "esm"
26
27
  },
27
28
  "binaryTargets": [
28
29
  {
@@ -39,8 +40,8 @@ const config = {
39
40
  "isCustomOutput": true
40
41
  },
41
42
  "relativePath": "../../test-files",
42
- "clientVersion": "6.18.0",
43
- "engineVersion": "34b5a692b7bd79939a9a2c3ef97d816e749cda2f",
43
+ "clientVersion": "6.19.2",
44
+ "engineVersion": "c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
44
45
  "datasourceNames": [
45
46
  "db"
46
47
  ],
@@ -58,8 +58,8 @@ export type PrismaVersion = {
58
58
  engine: string;
59
59
  };
60
60
  /**
61
- * Prisma Client JS version: 6.18.0
62
- * Query Engine version: 34b5a692b7bd79939a9a2c3ef97d816e749cda2f
61
+ * Prisma Client JS version: 6.19.2
62
+ * Query Engine version: c2990dca591cba766e3b7ef5d9e8a84796e47ab7
63
63
  */
64
64
  export declare const prismaVersion: PrismaVersion;
65
65
  /**
@@ -1,5 +1,6 @@
1
1
  /* !!! This is code generated by Prisma. Do not edit directly. !!! */
2
2
  /* eslint-disable */
3
+ // biome-ignore-all lint: generated file
3
4
  /*
4
5
  * WARNING: This is an internal file that is subject to change!
5
6
  *
@@ -38,12 +39,12 @@ export const skip = runtime.skip;
38
39
  export const Decimal = runtime.Decimal;
39
40
  export const getExtensionContext = runtime.Extensions.getExtensionContext;
40
41
  /**
41
- * Prisma Client JS version: 6.18.0
42
- * Query Engine version: 34b5a692b7bd79939a9a2c3ef97d816e749cda2f
42
+ * Prisma Client JS version: 6.19.2
43
+ * Query Engine version: c2990dca591cba766e3b7ef5d9e8a84796e47ab7
43
44
  */
44
45
  export const prismaVersion = {
45
- client: "6.18.0",
46
- engine: "34b5a692b7bd79939a9a2c3ef97d816e749cda2f"
46
+ client: "6.19.2",
47
+ engine: "c2990dca591cba766e3b7ef5d9e8a84796e47ab7"
47
48
  };
48
49
  export const NullTypes = {
49
50
  DbNull: runtime.objectEnumValues.classes.DbNull,
@@ -1,5 +1,6 @@
1
1
  /* !!! This is code generated by Prisma. Do not edit directly. !!! */
2
2
  /* eslint-disable */
3
+ // biome-ignore-all lint: generated file
3
4
  // @ts-nocheck
4
5
  /*
5
6
  * WARNING: This is an internal file that is subject to change!
package/dist/jwt/jwt.d.ts CHANGED
@@ -60,6 +60,18 @@ export type CreateJwtParams = Readonly<{
60
60
  */
61
61
  notValidUntil: DateLike;
62
62
  }>>;
63
+ /**
64
+ * JWT uses seconds since the epoch per RFC 7519, whereas `toTimestamp` uses milliseconds.
65
+ *
66
+ * @category Internal
67
+ */
68
+ export declare function toJwtTimestamp(date: Readonly<FullDate>): number;
69
+ /**
70
+ * Converts a JWT timestamp (in seconds) into a FullDate instance.
71
+ *
72
+ * @category Internal
73
+ */
74
+ export declare function parseJwtTimestamp(seconds: number): FullDate<UtcTimezone>;
63
75
  /**
64
76
  * Creates a signed and encrypted JWT that contains the given data.
65
77
  *
package/dist/jwt/jwt.js CHANGED
@@ -1,8 +1,24 @@
1
1
  import { assertWrap, check } from '@augment-vir/assert';
2
- import { calculateRelativeDate, createFullDateInUserTimezone, createUtcFullDate, getNowInUtcTimezone, isDateAfter, toTimestamp, } from 'date-vir';
2
+ import { calculateRelativeDate, createFullDateInUserTimezone, createUtcFullDate, getNowInUtcTimezone, toTimestamp, } from 'date-vir';
3
3
  import { EncryptJWT, jwtDecrypt, jwtVerify, SignJWT } from 'jose';
4
4
  const encryptionProtectedHeader = { alg: 'dir', enc: 'A256GCM' };
5
5
  const signingProtectedHeader = { alg: 'HS512' };
6
+ /**
7
+ * JWT uses seconds since the epoch per RFC 7519, whereas `toTimestamp` uses milliseconds.
8
+ *
9
+ * @category Internal
10
+ */
11
+ export function toJwtTimestamp(date) {
12
+ return Math.floor(toTimestamp(date) / 1000);
13
+ }
14
+ /**
15
+ * Converts a JWT timestamp (in seconds) into a FullDate instance.
16
+ *
17
+ * @category Internal
18
+ */
19
+ export function parseJwtTimestamp(seconds) {
20
+ return createUtcFullDate(seconds * 1000);
21
+ }
6
22
  /**
7
23
  * Creates a signed and encrypted JWT that contains the given data.
8
24
  *
@@ -14,13 +30,13 @@ data, params) {
14
30
  const rawJwt = new SignJWT({ data })
15
31
  .setProtectedHeader(signingProtectedHeader)
16
32
  .setIssuedAt(params.issuedAt
17
- ? toTimestamp(createFullDateInUserTimezone(params.issuedAt))
33
+ ? toJwtTimestamp(createFullDateInUserTimezone(params.issuedAt))
18
34
  : undefined)
19
35
  .setIssuer(params.issuer)
20
36
  .setAudience(params.audience)
21
- .setExpirationTime(toTimestamp(calculateRelativeDate(getNowInUtcTimezone(), params.jwtDuration)));
37
+ .setExpirationTime(toJwtTimestamp(calculateRelativeDate(getNowInUtcTimezone(), params.jwtDuration)));
22
38
  if (params.notValidUntil) {
23
- rawJwt.setNotBefore(toTimestamp(createFullDateInUserTimezone(params.notValidUntil)));
39
+ rawJwt.setNotBefore(toJwtTimestamp(createFullDateInUserTimezone(params.notValidUntil)));
24
40
  }
25
41
  const signedJwt = await rawJwt.sign(params.jwtKeys.signingKey);
26
42
  return await new EncryptJWT({ jwt: signedJwt })
@@ -57,14 +73,8 @@ export async function parseJwt(encryptedJwt, params) {
57
73
  if (!check.deepEquals(verifiedJwt.protectedHeader, signingProtectedHeader)) {
58
74
  throw new Error('Invalid signing protected header.');
59
75
  }
60
- const expirationMs = assertWrap.isDefined(verifiedJwt.payload.exp, 'JWT has no expiration.');
61
- const jwtExpiration = createUtcFullDate(expirationMs);
62
- if (isDateAfter({
63
- fullDate: getNowInUtcTimezone(),
64
- relativeTo: jwtExpiration,
65
- })) {
66
- throw new Error('JWT expired.');
67
- }
76
+ const expirationSeconds = assertWrap.isDefined(verifiedJwt.payload.exp, 'JWT has no expiration.');
77
+ const jwtExpiration = parseJwtTimestamp(expirationSeconds);
68
78
  return {
69
79
  data: data,
70
80
  jwtExpiration,
@@ -13,6 +13,12 @@ export declare const userJwtDataShape: import("object-shape-tester").Shape<{
13
13
  * Consider using {@link generateCsrfToken} to generate this.
14
14
  */
15
15
  csrfToken: string;
16
+ /**
17
+ * Unix timestamp (in milliseconds) when the session was originally started. This is used to
18
+ * enforce the max session duration. If not present, the session is considered to have started
19
+ * when the JWT was issued.
20
+ */
21
+ sessionStartedAt: import("object-shape-tester").Shape<import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TUndefined, import("@sinclair/typebox").TNumber]>>>;
16
22
  }>;
17
23
  /**
18
24
  * Data required for user JWTs.
@@ -1,4 +1,4 @@
1
- import { checkValidShape, defineShape, unionShape } from 'object-shape-tester';
1
+ import { checkValidShape, defineShape, optionalShape, unionShape } from 'object-shape-tester';
2
2
  import { createJwt, parseJwt, } from './jwt.js';
3
3
  /**
4
4
  * Shape definition and source of truth for {@link JwtUserData}.
@@ -14,6 +14,12 @@ export const userJwtDataShape = defineShape({
14
14
  * Consider using {@link generateCsrfToken} to generate this.
15
15
  */
16
16
  csrfToken: '',
17
+ /**
18
+ * Unix timestamp (in milliseconds) when the session was originally started. This is used to
19
+ * enforce the max session duration. If not present, the session is considered to have started
20
+ * when the JWT was issued.
21
+ */
22
+ sessionStartedAt: optionalShape(0, { alsoUndefined: true }),
17
23
  });
18
24
  /**
19
25
  * Creates a new signed and encrypted {@link JwtUserData} when a client (frontend) successfully
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auth-vir",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Auth made easy and secure via JWT cookies, CSRF tokens, and password hashing helpers.",
5
5
  "keywords": [
6
6
  "auth",
@@ -42,18 +42,18 @@
42
42
  "test:web": "virmator test web"
43
43
  },
44
44
  "dependencies": {
45
- "@augment-vir/assert": "^31.47.0",
46
- "@augment-vir/common": "^31.47.0",
47
- "date-vir": "^8.0.0",
45
+ "@augment-vir/assert": "^31.59.0",
46
+ "@augment-vir/common": "^31.59.0",
47
+ "date-vir": "^8.1.0",
48
48
  "detect-activity": "^0.0.1",
49
49
  "hash-wasm": "^4.12.0",
50
- "jose": "^6.1.0",
51
- "object-shape-tester": "^6.9.3",
52
- "type-fest": "^5.1.0",
53
- "url-vir": "^2.1.6"
50
+ "jose": "^6.1.3",
51
+ "object-shape-tester": "^6.11.0",
52
+ "type-fest": "^5.4.1",
53
+ "url-vir": "^2.1.7"
54
54
  },
55
55
  "devDependencies": {
56
- "@augment-vir/test": "^31.47.0",
56
+ "@augment-vir/test": "^31.59.0",
57
57
  "@prisma/client": "^6.18.0",
58
58
  "@types/node": "^24.9.1",
59
59
  "@web/dev-server-esbuild": "^1.0.4",
@@ -63,8 +63,8 @@
63
63
  "@web/test-runner-visual-regression": "^0.10.0",
64
64
  "istanbul-smart-text-reporter": "^1.1.5",
65
65
  "markdown-code-example-inserter": "^3.0.3",
66
- "prisma-vir": "^2.1.0",
67
- "typedoc": "^0.28.14"
66
+ "prisma-vir": "^2.3.3",
67
+ "typedoc": "^0.28.16"
68
68
  },
69
69
  "engines": {
70
70
  "node": ">=22"
@@ -5,7 +5,14 @@ import {
5
5
  type MaybePromise,
6
6
  type PartialWithUndefined,
7
7
  } from '@augment-vir/common';
8
- import {calculateRelativeDate, getNowInUtcTimezone, isDateAfter, type AnyDuration} from 'date-vir';
8
+ import {
9
+ calculateRelativeDate,
10
+ createUtcFullDate,
11
+ getNowInUtcTimezone,
12
+ isDateAfter,
13
+ negateDuration,
14
+ type AnyDuration,
15
+ } from 'date-vir';
9
16
  import {type IncomingHttpHeaders, type OutgoingHttpHeaders} from 'node:http';
10
17
  import {type EmptyObject, type RequireExactlyOne, type RequireOneOrNone} from 'type-fest';
11
18
  import {
@@ -127,12 +134,18 @@ export type BackendAuthClientConfig<
127
134
  */
128
135
  userSessionIdleTimeout: Readonly<AnyDuration>;
129
136
  /**
130
- * How long before a user's session times out when we should start trying to refresh their
131
- * session.
137
+ * How long into a user's session when we should start trying to refresh their session.
138
+ *
139
+ * @default {minutes: 2}
140
+ */
141
+ sessionRefreshTimeout: Readonly<AnyDuration>;
142
+ /**
143
+ * The maximum duration a session can last, regardless of activity. After this time, the
144
+ * user will be logged out even if they are actively using the application.
132
145
  *
133
- * @default {minutes: 10}
146
+ * @default {weeks: 2}
134
147
  */
135
- sessionRefreshThreshold: Readonly<AnyDuration>;
148
+ maxSessionDuration: Readonly<AnyDuration>;
136
149
  overrides: PartialWithUndefined<{
137
150
  csrfHeaderName: CsrfHeaderName;
138
151
  assumedUserHeaderName: string;
@@ -144,8 +157,12 @@ const defaultSessionIdleTimeout: Readonly<AnyDuration> = {
144
157
  minutes: 20,
145
158
  };
146
159
 
147
- const defaultSessionRefreshThreshold: Readonly<AnyDuration> = {
148
- minutes: 10,
160
+ const defaultSessionRefreshTimeout: Readonly<AnyDuration> = {
161
+ minutes: 2,
162
+ };
163
+
164
+ const defaultMaxSessionDuration: Readonly<AnyDuration> = {
165
+ weeks: 2,
149
166
  };
150
167
 
151
168
  /**
@@ -246,6 +263,24 @@ export class BackendAuthClient<
246
263
  return undefined;
247
264
  }
248
265
 
266
+ /**
267
+ * Check if the session has exceeded the max session duration. If so, don't refresh the
268
+ * session and let it expire naturally.
269
+ */
270
+ const maxSessionDuration = this.config.maxSessionDuration || defaultMaxSessionDuration;
271
+ if (userIdResult.sessionStartedAt) {
272
+ const sessionStartDate = createUtcFullDate(userIdResult.sessionStartedAt);
273
+ const maxSessionEndDate = calculateRelativeDate(sessionStartDate, maxSessionDuration);
274
+ const isSessionExpired = isDateAfter({
275
+ fullDate: now,
276
+ relativeTo: maxSessionEndDate,
277
+ });
278
+
279
+ if (isSessionExpired) {
280
+ return undefined;
281
+ }
282
+ }
283
+
249
284
  /**
250
285
  * This check performs the following: the current time + the refresh threshold > JWT
251
286
  * expiration.
@@ -261,12 +296,14 @@ export class BackendAuthClient<
261
296
  * - Y = JWT expiration within the refresh threshold: {@link isRefreshReady} = true.
262
297
  * - Z = JWT expiration outside the refresh threshold: {@link isRefreshReady} = false.
263
298
  */
299
+ const sessionRefreshTimeout =
300
+ this.config.sessionRefreshTimeout || defaultSessionRefreshTimeout;
264
301
  const isRefreshReady = isDateAfter({
265
- fullDate: calculateRelativeDate(
266
- now,
267
- this.config.sessionRefreshThreshold || defaultSessionRefreshThreshold,
302
+ fullDate: now,
303
+ relativeTo: calculateRelativeDate(
304
+ userIdResult.jwtExpiration,
305
+ negateDuration(sessionRefreshTimeout),
268
306
  ),
269
- relativeTo: userIdResult.jwtExpiration,
270
307
  });
271
308
 
272
309
  if (isRefreshReady) {
@@ -471,6 +508,14 @@ export class BackendAuthClient<
471
508
  )
472
509
  : undefined;
473
510
 
511
+ const existingUserIdResult = await extractUserIdFromRequestHeaders<UserId>(
512
+ requestHeaders,
513
+ await this.getJwtParams(),
514
+ isSignUpCookie ? AuthCookieName.SignUp : AuthCookieName.Auth,
515
+ this.config.overrides,
516
+ );
517
+ const sessionStartedAt = existingUserIdResult?.sessionStartedAt;
518
+
474
519
  const newCookieHeaders = await generateSuccessfulLoginHeaders(
475
520
  userId,
476
521
  await this.getCookieParams({
@@ -478,6 +523,7 @@ export class BackendAuthClient<
478
523
  requestHeaders,
479
524
  }),
480
525
  this.config.overrides,
526
+ sessionStartedAt,
481
527
  );
482
528
 
483
529
  return {
@@ -81,10 +81,12 @@ export type FrontendAuthClientConfig = PartialWithUndefined<{
81
81
  */
82
82
  export class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject = EmptyObject> {
83
83
  protected userCheckInterval: undefined | ReturnType<typeof createBlockingInterval>;
84
+ /** Used to clean up the activity listener on `.destroy()`. */
85
+ protected removeActivityListener: VoidFunction | undefined;
84
86
 
85
87
  constructor(protected readonly config: FrontendAuthClientConfig = {}) {
86
88
  if (config.checkUser) {
87
- listenToActivity({
89
+ this.removeActivityListener = listenToActivity({
88
90
  listener: async () => {
89
91
  const response = await config.checkUser?.performCheck();
90
92
 
@@ -106,6 +108,7 @@ export class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject =
106
108
  */
107
109
  public destroy() {
108
110
  this.userCheckInterval?.clearInterval();
111
+ this.removeActivityListener?.();
109
112
  }
110
113
 
111
114
  /** Wraps {@link getCurrentCsrfToken} to automatically handle wiping an invalid CSRF token. */
package/src/auth.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  } from './csrf-token.js';
17
17
  import {AuthHeaderName} from './headers.js';
18
18
  import {type ParseJwtParams} from './jwt/jwt.js';
19
+ import {type JwtUserData} from './jwt/user-jwt.js';
19
20
 
20
21
  /**
21
22
  * All possible headers container types supported by {@link extractUserIdFromRequestHeaders}.
@@ -49,6 +50,11 @@ export type UserIdResult<UserId extends string | number> = {
49
50
  userId: UserId;
50
51
  jwtExpiration: FullDate<UtcTimezone>;
51
52
  cookieName: string;
53
+ /**
54
+ * Unix timestamp (in milliseconds) when the session was originally started. Used to enforce max
55
+ * session duration.
56
+ */
57
+ sessionStartedAt: JwtUserData['sessionStartedAt'];
52
58
  };
53
59
 
54
60
  function readCsrfTokenHeader(
@@ -100,6 +106,7 @@ export async function extractUserIdFromRequestHeaders<UserId extends string | nu
100
106
  userId: jwt.data.userId as UserId,
101
107
  jwtExpiration: jwt.jwtExpiration,
102
108
  cookieName,
109
+ sessionStartedAt: jwt.data.sessionStartedAt,
103
110
  };
104
111
  } catch {
105
112
  return undefined;
@@ -136,6 +143,7 @@ export async function insecureExtractUserIdFromCookieAlone<UserId extends string
136
143
  userId: jwt.data.userId as UserId,
137
144
  jwtExpiration: jwt.jwtExpiration,
138
145
  cookieName,
146
+ sessionStartedAt: jwt.data.sessionStartedAt,
139
147
  };
140
148
  } catch {
141
149
  return undefined;
@@ -156,6 +164,11 @@ export async function generateSuccessfulLoginHeaders<
156
164
  overrides: PartialWithUndefined<{
157
165
  csrfHeaderName: CsrfHeaderName;
158
166
  }> = {},
167
+ /**
168
+ * The timestamp (in seconds) when the session originally started. If not provided, the current
169
+ * time will be used (for new sessions).
170
+ */
171
+ sessionStartedAt?: number | undefined,
159
172
  ): Promise<
160
173
  {
161
174
  'set-cookie': string;
@@ -169,6 +182,7 @@ export async function generateSuccessfulLoginHeaders<
169
182
  {
170
183
  csrfToken: csrfToken.token,
171
184
  userId,
185
+ sessionStartedAt: sessionStartedAt ?? Date.now(),
172
186
  },
173
187
  cookieConfig,
174
188
  ),
@@ -1,6 +1,7 @@
1
1
 
2
2
  /* !!! This is code generated by Prisma. Do not edit directly. !!! */
3
3
  /* eslint-disable */
4
+ // biome-ignore-all lint: generated file
4
5
  // @ts-nocheck
5
6
  /*
6
7
  * This file should be your main import to use Prisma-related types and utilities in a browser.
@@ -1,6 +1,7 @@
1
1
 
2
2
  /* !!! This is code generated by Prisma. Do not edit directly. !!! */
3
3
  /* eslint-disable */
4
+ // biome-ignore-all lint: generated file
4
5
  // @ts-nocheck
5
6
  /*
6
7
  * This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
@@ -3,6 +3,7 @@ import {type UtcIsoString} from 'date-vir';
3
3
 
4
4
  /* !!! This is code generated by Prisma. Do not edit directly. !!! */
5
5
  /* eslint-disable */
6
+ // biome-ignore-all lint: generated file
6
7
  /*
7
8
  * This file exports various common sort, input & filter types that are not directly linked to a particular model.
8
9
  *
@@ -1,6 +1,7 @@
1
1
 
2
2
  /* !!! This is code generated by Prisma. Do not edit directly. !!! */
3
3
  /* eslint-disable */
4
+ // biome-ignore-all lint: generated file
4
5
  // @ts-nocheck
5
6
  /*
6
7
  * This file exports all enum related types from the schema.
@@ -1,6 +1,7 @@
1
1
 
2
2
  /* !!! This is code generated by Prisma. Do not edit directly. !!! */
3
3
  /* eslint-disable */
4
+ // biome-ignore-all lint: generated file
4
5
  // @ts-nocheck
5
6
  /*
6
7
  * WARNING: This is an internal file that is subject to change!
@@ -26,8 +27,8 @@ const config: runtime.GetPrismaClientConfig = {
26
27
  "fromEnvVar": null
27
28
  },
28
29
  "config": {
29
- "moduleFormat": "esm",
30
- "engineType": "client"
30
+ "engineType": "client",
31
+ "moduleFormat": "esm"
31
32
  },
32
33
  "binaryTargets": [
33
34
  {
@@ -44,8 +45,8 @@ const config: runtime.GetPrismaClientConfig = {
44
45
  "isCustomOutput": true
45
46
  },
46
47
  "relativePath": "../../test-files",
47
- "clientVersion": "6.18.0",
48
- "engineVersion": "34b5a692b7bd79939a9a2c3ef97d816e749cda2f",
48
+ "clientVersion": "6.19.2",
49
+ "engineVersion": "c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
49
50
  "datasourceNames": [
50
51
  "db"
51
52
  ],
@@ -3,6 +3,7 @@ import {type UtcIsoString} from 'date-vir';
3
3
 
4
4
  /* !!! This is code generated by Prisma. Do not edit directly. !!! */
5
5
  /* eslint-disable */
6
+ // biome-ignore-all lint: generated file
6
7
  /*
7
8
  * WARNING: This is an internal file that is subject to change!
8
9
  *
@@ -93,12 +94,12 @@ export type PrismaVersion = {
93
94
  }
94
95
 
95
96
  /**
96
- * Prisma Client JS version: 6.18.0
97
- * Query Engine version: 34b5a692b7bd79939a9a2c3ef97d816e749cda2f
97
+ * Prisma Client JS version: 6.19.2
98
+ * Query Engine version: c2990dca591cba766e3b7ef5d9e8a84796e47ab7
98
99
  */
99
100
  export const prismaVersion: PrismaVersion = {
100
- client: "6.18.0",
101
- engine: "34b5a692b7bd79939a9a2c3ef97d816e749cda2f"
101
+ client: "6.19.2",
102
+ engine: "c2990dca591cba766e3b7ef5d9e8a84796e47ab7"
102
103
  }
103
104
 
104
105
  /**
@@ -1,6 +1,7 @@
1
1
 
2
2
  /* !!! This is code generated by Prisma. Do not edit directly. !!! */
3
3
  /* eslint-disable */
4
+ // biome-ignore-all lint: generated file
4
5
  // @ts-nocheck
5
6
  /*
6
7
  * WARNING: This is an internal file that is subject to change!
@@ -7,6 +7,7 @@ import {type UtcIsoString} from 'date-vir';
7
7
 
8
8
  /* !!! This is code generated by Prisma. Do not edit directly. !!! */
9
9
  /* eslint-disable */
10
+ // biome-ignore-all lint: generated file
10
11
  /*
11
12
  * This file exports the `User` model and its related types.
12
13
  *
@@ -1,6 +1,7 @@
1
1
 
2
2
  /* !!! This is code generated by Prisma. Do not edit directly. !!! */
3
3
  /* eslint-disable */
4
+ // biome-ignore-all lint: generated file
4
5
  // @ts-nocheck
5
6
  /*
6
7
  * This is a barrel export file for all models and their related types.
package/src/jwt/jwt.ts CHANGED
@@ -8,7 +8,6 @@ import {
8
8
  type DateLike,
9
9
  type FullDate,
10
10
  getNowInUtcTimezone,
11
- isDateAfter,
12
11
  toTimestamp,
13
12
  type UtcTimezone,
14
13
  } from 'date-vir';
@@ -81,6 +80,24 @@ export type CreateJwtParams = Readonly<{
81
80
  }>
82
81
  >;
83
82
 
83
+ /**
84
+ * JWT uses seconds since the epoch per RFC 7519, whereas `toTimestamp` uses milliseconds.
85
+ *
86
+ * @category Internal
87
+ */
88
+ export function toJwtTimestamp(date: Readonly<FullDate>) {
89
+ return Math.floor(toTimestamp(date) / 1000);
90
+ }
91
+
92
+ /**
93
+ * Converts a JWT timestamp (in seconds) into a FullDate instance.
94
+ *
95
+ * @category Internal
96
+ */
97
+ export function parseJwtTimestamp(seconds: number): FullDate<UtcTimezone> {
98
+ return createUtcFullDate(seconds * 1000);
99
+ }
100
+
84
101
  /**
85
102
  * Creates a signed and encrypted JWT that contains the given data.
86
103
  *
@@ -95,17 +112,17 @@ export async function createJwt<JwtData extends AnyObject = AnyObject>(
95
112
  .setProtectedHeader(signingProtectedHeader)
96
113
  .setIssuedAt(
97
114
  params.issuedAt
98
- ? toTimestamp(createFullDateInUserTimezone(params.issuedAt))
115
+ ? toJwtTimestamp(createFullDateInUserTimezone(params.issuedAt))
99
116
  : undefined,
100
117
  )
101
118
  .setIssuer(params.issuer)
102
119
  .setAudience(params.audience)
103
120
  .setExpirationTime(
104
- toTimestamp(calculateRelativeDate(getNowInUtcTimezone(), params.jwtDuration)),
121
+ toJwtTimestamp(calculateRelativeDate(getNowInUtcTimezone(), params.jwtDuration)),
105
122
  );
106
123
 
107
124
  if (params.notValidUntil) {
108
- rawJwt.setNotBefore(toTimestamp(createFullDateInUserTimezone(params.notValidUntil)));
125
+ rawJwt.setNotBefore(toJwtTimestamp(createFullDateInUserTimezone(params.notValidUntil)));
109
126
  }
110
127
 
111
128
  const signedJwt = await rawJwt.sign(params.jwtKeys.signingKey);
@@ -170,17 +187,12 @@ export async function parseJwt<JwtData extends AnyObject = AnyObject>(
170
187
  throw new Error('Invalid signing protected header.');
171
188
  }
172
189
 
173
- const expirationMs = assertWrap.isDefined(verifiedJwt.payload.exp, 'JWT has no expiration.');
174
- const jwtExpiration: FullDate<UtcTimezone> = createUtcFullDate(expirationMs);
190
+ const expirationSeconds = assertWrap.isDefined(
191
+ verifiedJwt.payload.exp,
192
+ 'JWT has no expiration.',
193
+ );
175
194
 
176
- if (
177
- isDateAfter({
178
- fullDate: getNowInUtcTimezone(),
179
- relativeTo: jwtExpiration,
180
- })
181
- ) {
182
- throw new Error('JWT expired.');
183
- }
195
+ const jwtExpiration: FullDate<UtcTimezone> = parseJwtTimestamp(expirationSeconds);
184
196
 
185
197
  return {
186
198
  data: data as JwtData,
@@ -1,4 +1,4 @@
1
- import {checkValidShape, defineShape, unionShape} from 'object-shape-tester';
1
+ import {checkValidShape, defineShape, optionalShape, unionShape} from 'object-shape-tester';
2
2
  import {type generateCsrfToken} from '../csrf-token.js';
3
3
  import {
4
4
  createJwt,
@@ -22,6 +22,12 @@ export const userJwtDataShape = defineShape({
22
22
  * Consider using {@link generateCsrfToken} to generate this.
23
23
  */
24
24
  csrfToken: '',
25
+ /**
26
+ * Unix timestamp (in milliseconds) when the session was originally started. This is used to
27
+ * enforce the max session duration. If not present, the session is considered to have started
28
+ * when the JWT was issued.
29
+ */
30
+ sessionStartedAt: optionalShape(0, {alsoUndefined: true}),
25
31
  });
26
32
 
27
33
  /**