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.
- package/dist/auth-client/backend-auth.client.d.ts +24 -19
- package/dist/auth-client/backend-auth.client.js +43 -19
- package/dist/auth-client/frontend-auth.client.d.ts +2 -0
- package/dist/auth-client/frontend-auth.client.js +4 -1
- package/dist/auth.d.ts +12 -1
- package/dist/auth.js +9 -1
- package/dist/generated/browser.js +1 -0
- package/dist/generated/client.js +1 -0
- package/dist/generated/enums.js +1 -0
- package/dist/generated/internal/class.js +5 -4
- package/dist/generated/internal/prismaNamespace.d.ts +2 -2
- package/dist/generated/internal/prismaNamespace.js +5 -4
- package/dist/generated/internal/prismaNamespaceBrowser.js +1 -0
- package/dist/jwt/jwt.d.ts +12 -0
- package/dist/jwt/jwt.js +22 -12
- package/dist/jwt/user-jwt.d.ts +6 -0
- package/dist/jwt/user-jwt.js +7 -1
- package/package.json +11 -11
- package/src/auth-client/backend-auth.client.ts +79 -34
- package/src/auth-client/frontend-auth.client.ts +4 -1
- package/src/auth.ts +14 -0
- package/src/generated/browser.ts +1 -0
- package/src/generated/client.ts +1 -0
- package/src/generated/commonInputTypes.ts +1 -0
- package/src/generated/enums.ts +1 -0
- package/src/generated/internal/class.ts +5 -4
- package/src/generated/internal/prismaNamespace.ts +5 -4
- package/src/generated/internal/prismaNamespaceBrowser.ts +1 -0
- package/src/generated/models/User.ts +1 -0
- package/src/generated/models.ts +1 -0
- package/src/jwt/jwt.ts +26 -14
- package/src/jwt/user-jwt.ts +7 -1
|
@@ -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
|
|
100
|
-
*
|
|
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 {
|
|
115
|
+
* @default {weeks: 2}
|
|
103
116
|
*/
|
|
104
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
152
|
+
protected createCookieRefreshHeaders({ userIdResult, requestHeaders, }: {
|
|
141
153
|
userIdResult: Readonly<UserIdResult<UserId>>;
|
|
142
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
11
|
-
minutes:
|
|
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,
|
|
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,
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
};
|
package/dist/generated/client.js
CHANGED
|
@@ -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.
|
package/dist/generated/enums.js
CHANGED
|
@@ -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
|
-
"
|
|
25
|
-
"
|
|
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.
|
|
43
|
-
"engineVersion": "
|
|
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.
|
|
62
|
-
* Query Engine version:
|
|
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.
|
|
42
|
-
* Query Engine version:
|
|
42
|
+
* Prisma Client JS version: 6.19.2
|
|
43
|
+
* Query Engine version: c2990dca591cba766e3b7ef5d9e8a84796e47ab7
|
|
43
44
|
*/
|
|
44
45
|
export const prismaVersion = {
|
|
45
|
-
client: "6.
|
|
46
|
-
engine: "
|
|
46
|
+
client: "6.19.2",
|
|
47
|
+
engine: "c2990dca591cba766e3b7ef5d9e8a84796e47ab7"
|
|
47
48
|
};
|
|
48
49
|
export const NullTypes = {
|
|
49
50
|
DbNull: runtime.objectEnumValues.classes.DbNull,
|
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,
|
|
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
|
-
?
|
|
33
|
+
? toJwtTimestamp(createFullDateInUserTimezone(params.issuedAt))
|
|
18
34
|
: undefined)
|
|
19
35
|
.setIssuer(params.issuer)
|
|
20
36
|
.setAudience(params.audience)
|
|
21
|
-
.setExpirationTime(
|
|
37
|
+
.setExpirationTime(toJwtTimestamp(calculateRelativeDate(getNowInUtcTimezone(), params.jwtDuration)));
|
|
22
38
|
if (params.notValidUntil) {
|
|
23
|
-
rawJwt.setNotBefore(
|
|
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
|
|
61
|
-
const jwtExpiration =
|
|
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,
|
package/dist/jwt/user-jwt.d.ts
CHANGED
|
@@ -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.
|
package/dist/jwt/user-jwt.js
CHANGED
|
@@ -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.
|
|
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.
|
|
46
|
-
"@augment-vir/common": "^31.
|
|
47
|
-
"date-vir": "^8.
|
|
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.
|
|
51
|
-
"object-shape-tester": "^6.
|
|
52
|
-
"type-fest": "^5.1
|
|
53
|
-
"url-vir": "^2.1.
|
|
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.
|
|
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.
|
|
67
|
-
"typedoc": "^0.28.
|
|
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 {
|
|
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
|
|
124
|
-
*
|
|
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 {
|
|
146
|
+
* @default {weeks: 2}
|
|
127
147
|
*/
|
|
128
|
-
|
|
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
|
|
141
|
-
minutes:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
+
requestHeaders,
|
|
223
250
|
}: {
|
|
224
251
|
userIdResult: Readonly<UserIdResult<UserId>>;
|
|
225
|
-
|
|
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:
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
),
|
package/src/generated/browser.ts
CHANGED
package/src/generated/client.ts
CHANGED
|
@@ -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
|
*
|
package/src/generated/enums.ts
CHANGED
|
@@ -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
|
-
"
|
|
30
|
-
"
|
|
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.
|
|
48
|
-
"engineVersion": "
|
|
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.
|
|
97
|
-
* Query Engine version:
|
|
97
|
+
* Prisma Client JS version: 6.19.2
|
|
98
|
+
* Query Engine version: c2990dca591cba766e3b7ef5d9e8a84796e47ab7
|
|
98
99
|
*/
|
|
99
100
|
export const prismaVersion: PrismaVersion = {
|
|
100
|
-
client: "6.
|
|
101
|
-
engine: "
|
|
101
|
+
client: "6.19.2",
|
|
102
|
+
engine: "c2990dca591cba766e3b7ef5d9e8a84796e47ab7"
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
/**
|
package/src/generated/models.ts
CHANGED
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
|
-
?
|
|
115
|
+
? toJwtTimestamp(createFullDateInUserTimezone(params.issuedAt))
|
|
99
116
|
: undefined,
|
|
100
117
|
)
|
|
101
118
|
.setIssuer(params.issuer)
|
|
102
119
|
.setAudience(params.audience)
|
|
103
120
|
.setExpirationTime(
|
|
104
|
-
|
|
121
|
+
toJwtTimestamp(calculateRelativeDate(getNowInUtcTimezone(), params.jwtDuration)),
|
|
105
122
|
);
|
|
106
123
|
|
|
107
124
|
if (params.notValidUntil) {
|
|
108
|
-
rawJwt.setNotBefore(
|
|
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
|
|
174
|
-
|
|
190
|
+
const expirationSeconds = assertWrap.isDefined(
|
|
191
|
+
verifiedJwt.payload.exp,
|
|
192
|
+
'JWT has no expiration.',
|
|
193
|
+
);
|
|
175
194
|
|
|
176
|
-
|
|
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,
|
package/src/jwt/user-jwt.ts
CHANGED
|
@@ -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
|
/**
|