auth-vir 2.4.2 → 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.
@@ -58,6 +58,13 @@ export type BackendAuthClientConfig<DatabaseUser extends AnyObject, UserId exten
58
58
  */
59
59
  isDev: boolean;
60
60
  } & PartialWithUndefined<{
61
+ /**
62
+ * Optionally generate a service origin from request headers. The generated origin is used
63
+ * for set-cookie headers.
64
+ */
65
+ generateServiceOrigin(params: {
66
+ requestHeaders: Readonly<IncomingHttpHeaders>;
67
+ }): MaybePromise<undefined | string>;
61
68
  /**
62
69
  * Set this to allow specific users (determined by `canAssumeUser`) to assume the identity
63
70
  * of other users. This should only be used for admins so that they can troubleshoot user
@@ -96,12 +103,18 @@ export type BackendAuthClientConfig<DatabaseUser extends AnyObject, UserId exten
96
103
  */
97
104
  userSessionIdleTimeout: Readonly<AnyDuration>;
98
105
  /**
99
- * How long before a user's session times out when we should start trying to refresh their
100
- * session.
106
+ * How long into a user's session when we should start trying to refresh their session.
107
+ *
108
+ * @default {minutes: 2}
109
+ */
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.
101
114
  *
102
- * @default {minutes: 10}
115
+ * @default {weeks: 2}
103
116
  */
104
- sessionRefreshThreshold: Readonly<AnyDuration>;
117
+ maxSessionDuration: Readonly<AnyDuration>;
105
118
  overrides: PartialWithUndefined<{
106
119
  csrfHeaderName: CsrfHeaderName;
107
120
  assumedUserHeaderName: string;
@@ -119,7 +132,7 @@ export declare class BackendAuthClient<DatabaseUser extends AnyObject, UserId ex
119
132
  protected cachedParsedJwtKeys: Record<string, Readonly<JwtKeys>>;
120
133
  constructor(config: BackendAuthClientConfig<DatabaseUser, UserId, AssumedUserParams, CsrfHeaderName>);
121
134
  /** Get all the parameters used for cookie generation. */
122
- protected getCookieParams({ isSignUpCookie, serviceOrigin, }: {
135
+ protected getCookieParams({ isSignUpCookie, requestHeaders, }: {
123
136
  /**
124
137
  * Set this to `true` when we are setting the initial cookie right after a user signs up.
125
138
  * This allows them to auto-authorize when they verify their email address.
@@ -127,8 +140,7 @@ export declare class BackendAuthClient<DatabaseUser extends AnyObject, UserId ex
127
140
  * This should only be set to `true` when a new user is signing up.
128
141
  */
129
142
  isSignUpCookie: boolean;
130
- /** Overrides the client's already established `serviceOrigin`. */
131
- serviceOrigin?: string | undefined;
143
+ requestHeaders: Readonly<IncomingHttpHeaders> | undefined;
132
144
  }): Promise<Readonly<CookieParams>>;
133
145
  /** Calls the provided `getUserFromDatabase` config. */
134
146
  protected getDatabaseUser({ isSignUpCookie, userId, assumingUser, }: {
@@ -137,10 +149,9 @@ export declare class BackendAuthClient<DatabaseUser extends AnyObject, UserId ex
137
149
  isSignUpCookie: boolean;
138
150
  }): Promise<undefined | DatabaseUser>;
139
151
  /** Creates a `'cookie-set'` header to refresh the user's session cookie. */
140
- protected createCookieRefreshHeaders({ userIdResult, serviceOrigin, }: {
152
+ protected createCookieRefreshHeaders({ userIdResult, requestHeaders, }: {
141
153
  userIdResult: Readonly<UserIdResult<UserId>>;
142
- /** Overrides the client's already established `serviceOrigin`. */
143
- serviceOrigin?: string | undefined;
154
+ requestHeaders: IncomingHttpHeaders;
144
155
  }): Promise<OutgoingHttpHeaders | undefined>;
145
156
  /** Reads the user's assumed user headers and, if configured, gets the assumed user. */
146
157
  protected getAssumedUser({ headers, user, }: {
@@ -148,7 +159,7 @@ export declare class BackendAuthClient<DatabaseUser extends AnyObject, UserId ex
148
159
  headers: IncomingHttpHeaders;
149
160
  }): Promise<DatabaseUser | undefined>;
150
161
  /** Securely extract a user from their request headers. */
151
- getSecureUser({ requestHeaders, isSignUpCookie, allowUserAuthRefresh, serviceOrigin, }: {
162
+ getSecureUser({ requestHeaders, isSignUpCookie, allowUserAuthRefresh, }: {
152
163
  requestHeaders: IncomingHttpHeaders;
153
164
  isSignUpCookie: boolean;
154
165
  /**
@@ -157,8 +168,6 @@ export declare class BackendAuthClient<DatabaseUser extends AnyObject, UserId ex
157
168
  * with the frontend auth client's `checkUser.performCheck` callback.
158
169
  */
159
170
  allowUserAuthRefresh: boolean;
160
- /** Overrides the client's already established `serviceOrigin`. */
161
- serviceOrigin?: string | undefined;
162
171
  }): Promise<GetUserResult<DatabaseUser> | undefined>;
163
172
  /**
164
173
  * Get all the JWT params used when creating the auth cookie, in case you need them for
@@ -176,12 +185,10 @@ export declare class BackendAuthClient<DatabaseUser extends AnyObject, UserId ex
176
185
  'set-cookie': string[];
177
186
  }>;
178
187
  /** Use these headers to log a user in. */
179
- createLoginHeaders({ userId, requestHeaders, isSignUpCookie, serviceOrigin, }: {
188
+ createLoginHeaders({ userId, requestHeaders, isSignUpCookie, }: {
180
189
  userId: UserId;
181
190
  requestHeaders: IncomingHttpHeaders;
182
191
  isSignUpCookie: boolean;
183
- /** Overrides the client's already established `serviceOrigin`. */
184
- serviceOrigin?: string | undefined;
185
192
  }): Promise<OutgoingHttpHeaders>;
186
193
  /** Combines `.getInsecureUser()` and `.getSecureUser()` into one method. */
187
194
  getInsecureOrSecureUser(params: {
@@ -210,7 +217,7 @@ export declare class BackendAuthClient<DatabaseUser extends AnyObject, UserId ex
210
217
  * where JavaScript cannot be used to attach the CSRF token header to the request (like when
211
218
  * opening a PDF file). Use `.getSecureUser()` instead, whenever possible.
212
219
  */
213
- getInsecureUser({ requestHeaders, allowUserAuthRefresh, serviceOrigin, }: {
220
+ getInsecureUser({ requestHeaders, allowUserAuthRefresh, }: {
214
221
  requestHeaders: IncomingHttpHeaders;
215
222
  /**
216
223
  * If true, this method will generate headers to refresh the user's auth session. This
@@ -218,7 +225,5 @@ export declare class BackendAuthClient<DatabaseUser extends AnyObject, UserId ex
218
225
  * with the frontend auth client's `checkUser.performCheck` callback.
219
226
  */
220
227
  allowUserAuthRefresh: boolean;
221
- /** Overrides the client's already established `serviceOrigin`. */
222
- serviceOrigin?: string | undefined;
223
228
  }): Promise<GetUserResult<DatabaseUser> | undefined>;
224
229
  }
@@ -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
@@ -24,7 +27,10 @@ export class BackendAuthClient {
24
27
  this.config = config;
25
28
  }
26
29
  /** Get all the parameters used for cookie generation. */
27
- async getCookieParams({ isSignUpCookie, serviceOrigin, }) {
30
+ async getCookieParams({ isSignUpCookie, requestHeaders, }) {
31
+ const serviceOrigin = requestHeaders
32
+ ? await this.config.generateServiceOrigin?.({ requestHeaders })
33
+ : undefined;
28
34
  return {
29
35
  cookieDuration: this.config.userSessionIdleTimeout || defaultSessionIdleTimeout,
30
36
  hostOrigin: serviceOrigin || this.config.serviceOrigin,
@@ -49,7 +55,7 @@ export class BackendAuthClient {
49
55
  return authenticatedUser;
50
56
  }
51
57
  /** Creates a `'cookie-set'` header to refresh the user's session cookie. */
52
- async createCookieRefreshHeaders({ userIdResult, serviceOrigin, }) {
58
+ async createCookieRefreshHeaders({ userIdResult, requestHeaders, }) {
53
59
  const now = getNowInUtcTimezone();
54
60
  /** Double check that the JWT hasn't already expired. */
55
61
  const isExpiredAlready = isDateAfter({
@@ -59,6 +65,22 @@ export class BackendAuthClient {
59
65
  if (isExpiredAlready) {
60
66
  return undefined;
61
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
+ }
62
84
  /**
63
85
  * This check performs the following: the current time + the refresh threshold > JWT
64
86
  * expiration.
@@ -74,16 +96,16 @@ export class BackendAuthClient {
74
96
  * - Y = JWT expiration within the refresh threshold: {@link isRefreshReady} = true.
75
97
  * - Z = JWT expiration outside the refresh threshold: {@link isRefreshReady} = false.
76
98
  */
99
+ const sessionRefreshTimeout = this.config.sessionRefreshTimeout || defaultSessionRefreshTimeout;
77
100
  const isRefreshReady = isDateAfter({
78
- fullDate: calculateRelativeDate(now, this.config.sessionRefreshThreshold || defaultSessionRefreshThreshold),
79
- relativeTo: userIdResult.jwtExpiration,
101
+ fullDate: now,
102
+ relativeTo: calculateRelativeDate(userIdResult.jwtExpiration, negateDuration(sessionRefreshTimeout)),
80
103
  });
81
104
  if (isRefreshReady) {
82
105
  return this.createLoginHeaders({
83
- requestHeaders: {},
106
+ requestHeaders,
84
107
  userId: userIdResult.userId,
85
108
  isSignUpCookie: userIdResult.cookieName === AuthCookieName.SignUp,
86
- serviceOrigin,
87
109
  });
88
110
  }
89
111
  else {
@@ -111,7 +133,7 @@ export class BackendAuthClient {
111
133
  return assumedUser;
112
134
  }
113
135
  /** Securely extract a user from their request headers. */
114
- async getSecureUser({ requestHeaders, isSignUpCookie, allowUserAuthRefresh, serviceOrigin, }) {
136
+ async getSecureUser({ requestHeaders, isSignUpCookie, allowUserAuthRefresh, }) {
115
137
  const userIdResult = await extractUserIdFromRequestHeaders(requestHeaders, await this.getJwtParams(), isSignUpCookie ? AuthCookieName.SignUp : AuthCookieName.Auth, this.config.overrides);
116
138
  if (!userIdResult) {
117
139
  return undefined;
@@ -130,7 +152,7 @@ export class BackendAuthClient {
130
152
  });
131
153
  const cookieRefreshHeaders = (await this.createCookieRefreshHeaders({
132
154
  userIdResult,
133
- serviceOrigin,
155
+ requestHeaders,
134
156
  })) || {};
135
157
  return {
136
158
  user: assumedUser || user,
@@ -162,13 +184,13 @@ export class BackendAuthClient {
162
184
  const signUpCookieHeaders = params.allCookies || params.isSignUpCookie
163
185
  ? generateLogoutHeaders(await this.getCookieParams({
164
186
  isSignUpCookie: true,
165
- serviceOrigin: params.serviceOrigin,
187
+ requestHeaders: undefined,
166
188
  }), this.config.overrides)
167
189
  : undefined;
168
190
  const authCookieHeaders = params.allCookies || !params.isSignUpCookie
169
191
  ? generateLogoutHeaders(await this.getCookieParams({
170
192
  isSignUpCookie: false,
171
- serviceOrigin: params.serviceOrigin,
193
+ requestHeaders: undefined,
172
194
  }), this.config.overrides)
173
195
  : undefined;
174
196
  const setCookieHeader = {
@@ -184,19 +206,21 @@ export class BackendAuthClient {
184
206
  };
185
207
  }
186
208
  /** Use these headers to log a user in. */
187
- async createLoginHeaders({ userId, requestHeaders, isSignUpCookie, serviceOrigin, }) {
209
+ async createLoginHeaders({ userId, requestHeaders, isSignUpCookie, }) {
188
210
  const oppositeCookieName = isSignUpCookie ? AuthCookieName.Auth : AuthCookieName.SignUp;
189
211
  const hasExistingOppositeCookie = requestHeaders.cookie?.includes(`${oppositeCookieName}=`);
190
212
  const discardOppositeCookieHeaders = hasExistingOppositeCookie
191
213
  ? generateLogoutHeaders(await this.getCookieParams({
192
214
  isSignUpCookie: !isSignUpCookie,
193
- serviceOrigin,
215
+ requestHeaders,
194
216
  }), this.config.overrides)
195
217
  : undefined;
218
+ const existingUserIdResult = await extractUserIdFromRequestHeaders(requestHeaders, await this.getJwtParams(), isSignUpCookie ? AuthCookieName.SignUp : AuthCookieName.Auth, this.config.overrides);
219
+ const sessionStartedAt = existingUserIdResult?.sessionStartedAt;
196
220
  const newCookieHeaders = await generateSuccessfulLoginHeaders(userId, await this.getCookieParams({
197
221
  isSignUpCookie,
198
- serviceOrigin,
199
- }), this.config.overrides);
222
+ requestHeaders,
223
+ }), this.config.overrides, sessionStartedAt);
200
224
  return {
201
225
  ...newCookieHeaders,
202
226
  'set-cookie': mergeHeaderValues(newCookieHeaders['set-cookie'], discardOppositeCookieHeaders?.['set-cookie']),
@@ -222,7 +246,7 @@ export class BackendAuthClient {
222
246
  * where JavaScript cannot be used to attach the CSRF token header to the request (like when
223
247
  * opening a PDF file). Use `.getSecureUser()` instead, whenever possible.
224
248
  */
225
- async getInsecureUser({ requestHeaders, allowUserAuthRefresh, serviceOrigin, }) {
249
+ async getInsecureUser({ requestHeaders, allowUserAuthRefresh, }) {
226
250
  // eslint-disable-next-line @typescript-eslint/no-deprecated
227
251
  const userIdResult = await insecureExtractUserIdFromCookieAlone(requestHeaders, await this.getJwtParams(), AuthCookieName.Auth);
228
252
  if (!userIdResult) {
@@ -239,7 +263,7 @@ export class BackendAuthClient {
239
263
  const refreshHeaders = allowUserAuthRefresh &&
240
264
  (await this.createCookieRefreshHeaders({
241
265
  userIdResult,
242
- serviceOrigin,
266
+ requestHeaders,
243
267
  }));
244
268
  return {
245
269
  user,
@@ -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.4.2",
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 {
@@ -78,6 +85,13 @@ export type BackendAuthClientConfig<
78
85
  */
79
86
  isDev: boolean;
80
87
  } & PartialWithUndefined<{
88
+ /**
89
+ * Optionally generate a service origin from request headers. The generated origin is used
90
+ * for set-cookie headers.
91
+ */
92
+ generateServiceOrigin(params: {
93
+ requestHeaders: Readonly<IncomingHttpHeaders>;
94
+ }): MaybePromise<undefined | string>;
81
95
  /**
82
96
  * Set this to allow specific users (determined by `canAssumeUser`) to assume the identity
83
97
  * of other users. This should only be used for admins so that they can troubleshoot user
@@ -120,12 +134,18 @@ export type BackendAuthClientConfig<
120
134
  */
121
135
  userSessionIdleTimeout: Readonly<AnyDuration>;
122
136
  /**
123
- * How long before a user's session times out when we should start trying to refresh their
124
- * 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.
125
145
  *
126
- * @default {minutes: 10}
146
+ * @default {weeks: 2}
127
147
  */
128
- sessionRefreshThreshold: Readonly<AnyDuration>;
148
+ maxSessionDuration: Readonly<AnyDuration>;
129
149
  overrides: PartialWithUndefined<{
130
150
  csrfHeaderName: CsrfHeaderName;
131
151
  assumedUserHeaderName: string;
@@ -137,8 +157,12 @@ const defaultSessionIdleTimeout: Readonly<AnyDuration> = {
137
157
  minutes: 20,
138
158
  };
139
159
 
140
- const defaultSessionRefreshThreshold: Readonly<AnyDuration> = {
141
- minutes: 10,
160
+ const defaultSessionRefreshTimeout: Readonly<AnyDuration> = {
161
+ minutes: 2,
162
+ };
163
+
164
+ const defaultMaxSessionDuration: Readonly<AnyDuration> = {
165
+ weeks: 2,
142
166
  };
143
167
 
144
168
  /**
@@ -168,7 +192,7 @@ export class BackendAuthClient<
168
192
  /** Get all the parameters used for cookie generation. */
169
193
  protected async getCookieParams({
170
194
  isSignUpCookie,
171
- serviceOrigin,
195
+ requestHeaders,
172
196
  }: {
173
197
  /**
174
198
  * Set this to `true` when we are setting the initial cookie right after a user signs up.
@@ -177,9 +201,12 @@ export class BackendAuthClient<
177
201
  * This should only be set to `true` when a new user is signing up.
178
202
  */
179
203
  isSignUpCookie: boolean;
180
- /** Overrides the client's already established `serviceOrigin`. */
181
- serviceOrigin?: string | undefined;
204
+ requestHeaders: Readonly<IncomingHttpHeaders> | undefined;
182
205
  }): Promise<Readonly<CookieParams>> {
206
+ const serviceOrigin = requestHeaders
207
+ ? await this.config.generateServiceOrigin?.({requestHeaders})
208
+ : undefined;
209
+
183
210
  return {
184
211
  cookieDuration: this.config.userSessionIdleTimeout || defaultSessionIdleTimeout,
185
212
  hostOrigin: serviceOrigin || this.config.serviceOrigin,
@@ -219,11 +246,10 @@ export class BackendAuthClient<
219
246
  /** Creates a `'cookie-set'` header to refresh the user's session cookie. */
220
247
  protected async createCookieRefreshHeaders({
221
248
  userIdResult,
222
- serviceOrigin,
249
+ requestHeaders,
223
250
  }: {
224
251
  userIdResult: Readonly<UserIdResult<UserId>>;
225
- /** Overrides the client's already established `serviceOrigin`. */
226
- serviceOrigin?: string | undefined;
252
+ requestHeaders: IncomingHttpHeaders;
227
253
  }): Promise<OutgoingHttpHeaders | undefined> {
228
254
  const now = getNowInUtcTimezone();
229
255
 
@@ -237,6 +263,24 @@ export class BackendAuthClient<
237
263
  return undefined;
238
264
  }
239
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
+
240
284
  /**
241
285
  * This check performs the following: the current time + the refresh threshold > JWT
242
286
  * expiration.
@@ -252,20 +296,21 @@ export class BackendAuthClient<
252
296
  * - Y = JWT expiration within the refresh threshold: {@link isRefreshReady} = true.
253
297
  * - Z = JWT expiration outside the refresh threshold: {@link isRefreshReady} = false.
254
298
  */
299
+ const sessionRefreshTimeout =
300
+ this.config.sessionRefreshTimeout || defaultSessionRefreshTimeout;
255
301
  const isRefreshReady = isDateAfter({
256
- fullDate: calculateRelativeDate(
257
- now,
258
- this.config.sessionRefreshThreshold || defaultSessionRefreshThreshold,
302
+ fullDate: now,
303
+ relativeTo: calculateRelativeDate(
304
+ userIdResult.jwtExpiration,
305
+ negateDuration(sessionRefreshTimeout),
259
306
  ),
260
- relativeTo: userIdResult.jwtExpiration,
261
307
  });
262
308
 
263
309
  if (isRefreshReady) {
264
310
  return this.createLoginHeaders({
265
- requestHeaders: {},
311
+ requestHeaders,
266
312
  userId: userIdResult.userId,
267
313
  isSignUpCookie: userIdResult.cookieName === AuthCookieName.SignUp,
268
- serviceOrigin,
269
314
  });
270
315
  } else {
271
316
  return undefined;
@@ -313,7 +358,6 @@ export class BackendAuthClient<
313
358
  requestHeaders,
314
359
  isSignUpCookie,
315
360
  allowUserAuthRefresh,
316
- serviceOrigin,
317
361
  }: {
318
362
  requestHeaders: IncomingHttpHeaders;
319
363
  isSignUpCookie: boolean;
@@ -323,8 +367,6 @@ export class BackendAuthClient<
323
367
  * with the frontend auth client's `checkUser.performCheck` callback.
324
368
  */
325
369
  allowUserAuthRefresh: boolean;
326
- /** Overrides the client's already established `serviceOrigin`. */
327
- serviceOrigin?: string | undefined;
328
370
  }): Promise<GetUserResult<DatabaseUser> | undefined> {
329
371
  const userIdResult = await extractUserIdFromRequestHeaders<UserId>(
330
372
  requestHeaders,
@@ -354,7 +396,7 @@ export class BackendAuthClient<
354
396
  const cookieRefreshHeaders =
355
397
  (await this.createCookieRefreshHeaders({
356
398
  userIdResult,
357
- serviceOrigin,
399
+ requestHeaders,
358
400
  })) || {};
359
401
 
360
402
  return {
@@ -408,7 +450,7 @@ export class BackendAuthClient<
408
450
  ? (generateLogoutHeaders(
409
451
  await this.getCookieParams({
410
452
  isSignUpCookie: true,
411
- serviceOrigin: params.serviceOrigin,
453
+ requestHeaders: undefined,
412
454
  }),
413
455
  this.config.overrides,
414
456
  ) satisfies Record<CsrfHeaderName, string>)
@@ -418,7 +460,7 @@ export class BackendAuthClient<
418
460
  ? (generateLogoutHeaders(
419
461
  await this.getCookieParams({
420
462
  isSignUpCookie: false,
421
- serviceOrigin: params.serviceOrigin,
463
+ requestHeaders: undefined,
422
464
  }),
423
465
  this.config.overrides,
424
466
  ) satisfies Record<CsrfHeaderName, string>)
@@ -448,13 +490,10 @@ export class BackendAuthClient<
448
490
  userId,
449
491
  requestHeaders,
450
492
  isSignUpCookie,
451
- serviceOrigin,
452
493
  }: {
453
494
  userId: UserId;
454
495
  requestHeaders: IncomingHttpHeaders;
455
496
  isSignUpCookie: boolean;
456
- /** Overrides the client's already established `serviceOrigin`. */
457
- serviceOrigin?: string | undefined;
458
497
  }): Promise<OutgoingHttpHeaders> {
459
498
  const oppositeCookieName = isSignUpCookie ? AuthCookieName.Auth : AuthCookieName.SignUp;
460
499
  const hasExistingOppositeCookie = requestHeaders.cookie?.includes(`${oppositeCookieName}=`);
@@ -463,19 +502,28 @@ export class BackendAuthClient<
463
502
  ? generateLogoutHeaders(
464
503
  await this.getCookieParams({
465
504
  isSignUpCookie: !isSignUpCookie,
466
- serviceOrigin,
505
+ requestHeaders,
467
506
  }),
468
507
  this.config.overrides,
469
508
  )
470
509
  : undefined;
471
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
+
472
519
  const newCookieHeaders = await generateSuccessfulLoginHeaders(
473
520
  userId,
474
521
  await this.getCookieParams({
475
522
  isSignUpCookie,
476
- serviceOrigin,
523
+ requestHeaders,
477
524
  }),
478
525
  this.config.overrides,
526
+ sessionStartedAt,
479
527
  );
480
528
 
481
529
  return {
@@ -536,7 +584,6 @@ export class BackendAuthClient<
536
584
  public async getInsecureUser({
537
585
  requestHeaders,
538
586
  allowUserAuthRefresh,
539
- serviceOrigin,
540
587
  }: {
541
588
  requestHeaders: IncomingHttpHeaders;
542
589
  /**
@@ -545,8 +592,6 @@ export class BackendAuthClient<
545
592
  * with the frontend auth client's `checkUser.performCheck` callback.
546
593
  */
547
594
  allowUserAuthRefresh: boolean;
548
- /** Overrides the client's already established `serviceOrigin`. */
549
- serviceOrigin?: string | undefined;
550
595
  }): Promise<GetUserResult<DatabaseUser> | undefined> {
551
596
  // eslint-disable-next-line @typescript-eslint/no-deprecated
552
597
  const userIdResult = await insecureExtractUserIdFromCookieAlone<UserId>(
@@ -573,7 +618,7 @@ export class BackendAuthClient<
573
618
  allowUserAuthRefresh &&
574
619
  (await this.createCookieRefreshHeaders({
575
620
  userIdResult,
576
- serviceOrigin,
621
+ requestHeaders,
577
622
  }));
578
623
 
579
624
  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
  /**