auth-vir 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth-client/backend-auth.client.d.ts +10 -4
- package/dist/auth-client/backend-auth.client.js +28 -6
- 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 +57 -11
- 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
|
@@ -103,12 +103,18 @@ export type BackendAuthClientConfig<DatabaseUser extends AnyObject, UserId exten
|
|
|
103
103
|
*/
|
|
104
104
|
userSessionIdleTimeout: Readonly<AnyDuration>;
|
|
105
105
|
/**
|
|
106
|
-
* How long
|
|
107
|
-
* session.
|
|
106
|
+
* How long into a user's session when we should start trying to refresh their session.
|
|
108
107
|
*
|
|
109
|
-
* @default {minutes:
|
|
108
|
+
* @default {minutes: 2}
|
|
110
109
|
*/
|
|
111
|
-
|
|
110
|
+
sessionRefreshTimeout: Readonly<AnyDuration>;
|
|
111
|
+
/**
|
|
112
|
+
* The maximum duration a session can last, regardless of activity. After this time, the
|
|
113
|
+
* user will be logged out even if they are actively using the application.
|
|
114
|
+
*
|
|
115
|
+
* @default {weeks: 2}
|
|
116
|
+
*/
|
|
117
|
+
maxSessionDuration: Readonly<AnyDuration>;
|
|
112
118
|
overrides: PartialWithUndefined<{
|
|
113
119
|
csrfHeaderName: CsrfHeaderName;
|
|
114
120
|
assumedUserHeaderName: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ensureArray, } from '@augment-vir/common';
|
|
2
|
-
import { calculateRelativeDate, getNowInUtcTimezone, isDateAfter } from 'date-vir';
|
|
2
|
+
import { calculateRelativeDate, createUtcFullDate, getNowInUtcTimezone, isDateAfter, negateDuration, } from 'date-vir';
|
|
3
3
|
import { extractUserIdFromRequestHeaders, generateLogoutHeaders, generateSuccessfulLoginHeaders, insecureExtractUserIdFromCookieAlone, } from '../auth.js';
|
|
4
4
|
import { AuthCookieName } from '../cookie.js';
|
|
5
5
|
import { AuthHeaderName, mergeHeaderValues } from '../headers.js';
|
|
@@ -7,8 +7,11 @@ import { parseJwtKeys } from '../jwt/jwt-keys.js';
|
|
|
7
7
|
const defaultSessionIdleTimeout = {
|
|
8
8
|
minutes: 20,
|
|
9
9
|
};
|
|
10
|
-
const
|
|
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
|
|
@@ -62,6 +65,22 @@ export class BackendAuthClient {
|
|
|
62
65
|
if (isExpiredAlready) {
|
|
63
66
|
return undefined;
|
|
64
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Check if the session has exceeded the max session duration. If so, don't refresh the
|
|
70
|
+
* session and let it expire naturally.
|
|
71
|
+
*/
|
|
72
|
+
const maxSessionDuration = this.config.maxSessionDuration || defaultMaxSessionDuration;
|
|
73
|
+
if (userIdResult.sessionStartedAt) {
|
|
74
|
+
const sessionStartDate = createUtcFullDate(userIdResult.sessionStartedAt);
|
|
75
|
+
const maxSessionEndDate = calculateRelativeDate(sessionStartDate, maxSessionDuration);
|
|
76
|
+
const isSessionExpired = isDateAfter({
|
|
77
|
+
fullDate: now,
|
|
78
|
+
relativeTo: maxSessionEndDate,
|
|
79
|
+
});
|
|
80
|
+
if (isSessionExpired) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
65
84
|
/**
|
|
66
85
|
* This check performs the following: the current time + the refresh threshold > JWT
|
|
67
86
|
* expiration.
|
|
@@ -77,9 +96,10 @@ export class BackendAuthClient {
|
|
|
77
96
|
* - Y = JWT expiration within the refresh threshold: {@link isRefreshReady} = true.
|
|
78
97
|
* - Z = JWT expiration outside the refresh threshold: {@link isRefreshReady} = false.
|
|
79
98
|
*/
|
|
99
|
+
const sessionRefreshTimeout = this.config.sessionRefreshTimeout || defaultSessionRefreshTimeout;
|
|
80
100
|
const isRefreshReady = isDateAfter({
|
|
81
|
-
fullDate:
|
|
82
|
-
relativeTo: userIdResult.jwtExpiration,
|
|
101
|
+
fullDate: now,
|
|
102
|
+
relativeTo: calculateRelativeDate(userIdResult.jwtExpiration, negateDuration(sessionRefreshTimeout)),
|
|
83
103
|
});
|
|
84
104
|
if (isRefreshReady) {
|
|
85
105
|
return this.createLoginHeaders({
|
|
@@ -195,10 +215,12 @@ export class BackendAuthClient {
|
|
|
195
215
|
requestHeaders,
|
|
196
216
|
}), this.config.overrides)
|
|
197
217
|
: undefined;
|
|
218
|
+
const existingUserIdResult = await extractUserIdFromRequestHeaders(requestHeaders, await this.getJwtParams(), isSignUpCookie ? AuthCookieName.SignUp : AuthCookieName.Auth, this.config.overrides);
|
|
219
|
+
const sessionStartedAt = existingUserIdResult?.sessionStartedAt;
|
|
198
220
|
const newCookieHeaders = await generateSuccessfulLoginHeaders(userId, await this.getCookieParams({
|
|
199
221
|
isSignUpCookie,
|
|
200
222
|
requestHeaders,
|
|
201
|
-
}), this.config.overrides);
|
|
223
|
+
}), this.config.overrides, sessionStartedAt);
|
|
202
224
|
return {
|
|
203
225
|
...newCookieHeaders,
|
|
204
226
|
'set-cookie': mergeHeaderValues(newCookieHeaders['set-cookie'], discardOppositeCookieHeaders?.['set-cookie']),
|
|
@@ -56,6 +56,8 @@ export type FrontendAuthClientConfig = PartialWithUndefined<{
|
|
|
56
56
|
export declare class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject = EmptyObject> {
|
|
57
57
|
protected readonly config: FrontendAuthClientConfig;
|
|
58
58
|
protected userCheckInterval: undefined | ReturnType<typeof createBlockingInterval>;
|
|
59
|
+
/** Used to clean up the activity listener on `.destroy()`. */
|
|
60
|
+
protected removeActivityListener: VoidFunction | undefined;
|
|
59
61
|
constructor(config?: FrontendAuthClientConfig);
|
|
60
62
|
/**
|
|
61
63
|
* Destroys the client and performs all necessary cleanup (like clearing the user check
|
|
@@ -12,10 +12,12 @@ import { AuthHeaderName } from '../headers.js';
|
|
|
12
12
|
export class FrontendAuthClient {
|
|
13
13
|
config;
|
|
14
14
|
userCheckInterval;
|
|
15
|
+
/** Used to clean up the activity listener on `.destroy()`. */
|
|
16
|
+
removeActivityListener;
|
|
15
17
|
constructor(config = {}) {
|
|
16
18
|
this.config = config;
|
|
17
19
|
if (config.checkUser) {
|
|
18
|
-
listenToActivity({
|
|
20
|
+
this.removeActivityListener = listenToActivity({
|
|
19
21
|
listener: async () => {
|
|
20
22
|
const response = await config.checkUser?.performCheck();
|
|
21
23
|
if (response) {
|
|
@@ -35,6 +37,7 @@ export class FrontendAuthClient {
|
|
|
35
37
|
*/
|
|
36
38
|
destroy() {
|
|
37
39
|
this.userCheckInterval?.clearInterval();
|
|
40
|
+
this.removeActivityListener?.();
|
|
38
41
|
}
|
|
39
42
|
/** Wraps {@link getCurrentCsrfToken} to automatically handle wiping an invalid CSRF token. */
|
|
40
43
|
async getCurrentCsrfToken() {
|
package/dist/auth.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type FullDate, type UtcTimezone } from 'date-vir';
|
|
|
3
3
|
import { type CookieParams } from './cookie.js';
|
|
4
4
|
import { AuthHeaderName } from './headers.js';
|
|
5
5
|
import { type ParseJwtParams } from './jwt/jwt.js';
|
|
6
|
+
import { type JwtUserData } from './jwt/user-jwt.js';
|
|
6
7
|
/**
|
|
7
8
|
* All possible headers container types supported by {@link extractUserIdFromRequestHeaders}.
|
|
8
9
|
*
|
|
@@ -18,6 +19,11 @@ export type UserIdResult<UserId extends string | number> = {
|
|
|
18
19
|
userId: UserId;
|
|
19
20
|
jwtExpiration: FullDate<UtcTimezone>;
|
|
20
21
|
cookieName: string;
|
|
22
|
+
/**
|
|
23
|
+
* Unix timestamp (in milliseconds) when the session was originally started. Used to enforce max
|
|
24
|
+
* session duration.
|
|
25
|
+
*/
|
|
26
|
+
sessionStartedAt: JwtUserData['sessionStartedAt'];
|
|
21
27
|
};
|
|
22
28
|
/**
|
|
23
29
|
* Extract the user id from a request by checking both the request cookie and CSRF token. This is
|
|
@@ -48,7 +54,12 @@ export declare function generateSuccessfulLoginHeaders<CsrfHeaderName extends st
|
|
|
48
54
|
/** The id from your database of the user you're authenticating. */
|
|
49
55
|
userId: string | number, cookieConfig: Readonly<CookieParams>, overrides?: PartialWithUndefined<{
|
|
50
56
|
csrfHeaderName: CsrfHeaderName;
|
|
51
|
-
}
|
|
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 {
|
|
@@ -127,12 +134,18 @@ export type BackendAuthClientConfig<
|
|
|
127
134
|
*/
|
|
128
135
|
userSessionIdleTimeout: Readonly<AnyDuration>;
|
|
129
136
|
/**
|
|
130
|
-
* How long
|
|
131
|
-
*
|
|
137
|
+
* How long into a user's session when we should start trying to refresh their session.
|
|
138
|
+
*
|
|
139
|
+
* @default {minutes: 2}
|
|
140
|
+
*/
|
|
141
|
+
sessionRefreshTimeout: Readonly<AnyDuration>;
|
|
142
|
+
/**
|
|
143
|
+
* The maximum duration a session can last, regardless of activity. After this time, the
|
|
144
|
+
* user will be logged out even if they are actively using the application.
|
|
132
145
|
*
|
|
133
|
-
* @default {
|
|
146
|
+
* @default {weeks: 2}
|
|
134
147
|
*/
|
|
135
|
-
|
|
148
|
+
maxSessionDuration: Readonly<AnyDuration>;
|
|
136
149
|
overrides: PartialWithUndefined<{
|
|
137
150
|
csrfHeaderName: CsrfHeaderName;
|
|
138
151
|
assumedUserHeaderName: string;
|
|
@@ -144,8 +157,12 @@ const defaultSessionIdleTimeout: Readonly<AnyDuration> = {
|
|
|
144
157
|
minutes: 20,
|
|
145
158
|
};
|
|
146
159
|
|
|
147
|
-
const
|
|
148
|
-
minutes:
|
|
160
|
+
const defaultSessionRefreshTimeout: Readonly<AnyDuration> = {
|
|
161
|
+
minutes: 2,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const defaultMaxSessionDuration: Readonly<AnyDuration> = {
|
|
165
|
+
weeks: 2,
|
|
149
166
|
};
|
|
150
167
|
|
|
151
168
|
/**
|
|
@@ -246,6 +263,24 @@ export class BackendAuthClient<
|
|
|
246
263
|
return undefined;
|
|
247
264
|
}
|
|
248
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Check if the session has exceeded the max session duration. If so, don't refresh the
|
|
268
|
+
* session and let it expire naturally.
|
|
269
|
+
*/
|
|
270
|
+
const maxSessionDuration = this.config.maxSessionDuration || defaultMaxSessionDuration;
|
|
271
|
+
if (userIdResult.sessionStartedAt) {
|
|
272
|
+
const sessionStartDate = createUtcFullDate(userIdResult.sessionStartedAt);
|
|
273
|
+
const maxSessionEndDate = calculateRelativeDate(sessionStartDate, maxSessionDuration);
|
|
274
|
+
const isSessionExpired = isDateAfter({
|
|
275
|
+
fullDate: now,
|
|
276
|
+
relativeTo: maxSessionEndDate,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (isSessionExpired) {
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
249
284
|
/**
|
|
250
285
|
* This check performs the following: the current time + the refresh threshold > JWT
|
|
251
286
|
* expiration.
|
|
@@ -261,12 +296,14 @@ export class BackendAuthClient<
|
|
|
261
296
|
* - Y = JWT expiration within the refresh threshold: {@link isRefreshReady} = true.
|
|
262
297
|
* - Z = JWT expiration outside the refresh threshold: {@link isRefreshReady} = false.
|
|
263
298
|
*/
|
|
299
|
+
const sessionRefreshTimeout =
|
|
300
|
+
this.config.sessionRefreshTimeout || defaultSessionRefreshTimeout;
|
|
264
301
|
const isRefreshReady = isDateAfter({
|
|
265
|
-
fullDate:
|
|
266
|
-
|
|
267
|
-
|
|
302
|
+
fullDate: now,
|
|
303
|
+
relativeTo: calculateRelativeDate(
|
|
304
|
+
userIdResult.jwtExpiration,
|
|
305
|
+
negateDuration(sessionRefreshTimeout),
|
|
268
306
|
),
|
|
269
|
-
relativeTo: userIdResult.jwtExpiration,
|
|
270
307
|
});
|
|
271
308
|
|
|
272
309
|
if (isRefreshReady) {
|
|
@@ -471,6 +508,14 @@ export class BackendAuthClient<
|
|
|
471
508
|
)
|
|
472
509
|
: undefined;
|
|
473
510
|
|
|
511
|
+
const existingUserIdResult = await extractUserIdFromRequestHeaders<UserId>(
|
|
512
|
+
requestHeaders,
|
|
513
|
+
await this.getJwtParams(),
|
|
514
|
+
isSignUpCookie ? AuthCookieName.SignUp : AuthCookieName.Auth,
|
|
515
|
+
this.config.overrides,
|
|
516
|
+
);
|
|
517
|
+
const sessionStartedAt = existingUserIdResult?.sessionStartedAt;
|
|
518
|
+
|
|
474
519
|
const newCookieHeaders = await generateSuccessfulLoginHeaders(
|
|
475
520
|
userId,
|
|
476
521
|
await this.getCookieParams({
|
|
@@ -478,6 +523,7 @@ export class BackendAuthClient<
|
|
|
478
523
|
requestHeaders,
|
|
479
524
|
}),
|
|
480
525
|
this.config.overrides,
|
|
526
|
+
sessionStartedAt,
|
|
481
527
|
);
|
|
482
528
|
|
|
483
529
|
return {
|
|
@@ -81,10 +81,12 @@ export type FrontendAuthClientConfig = PartialWithUndefined<{
|
|
|
81
81
|
*/
|
|
82
82
|
export class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject = EmptyObject> {
|
|
83
83
|
protected userCheckInterval: undefined | ReturnType<typeof createBlockingInterval>;
|
|
84
|
+
/** Used to clean up the activity listener on `.destroy()`. */
|
|
85
|
+
protected removeActivityListener: VoidFunction | undefined;
|
|
84
86
|
|
|
85
87
|
constructor(protected readonly config: FrontendAuthClientConfig = {}) {
|
|
86
88
|
if (config.checkUser) {
|
|
87
|
-
listenToActivity({
|
|
89
|
+
this.removeActivityListener = listenToActivity({
|
|
88
90
|
listener: async () => {
|
|
89
91
|
const response = await config.checkUser?.performCheck();
|
|
90
92
|
|
|
@@ -106,6 +108,7 @@ export class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject =
|
|
|
106
108
|
*/
|
|
107
109
|
public destroy() {
|
|
108
110
|
this.userCheckInterval?.clearInterval();
|
|
111
|
+
this.removeActivityListener?.();
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
/** Wraps {@link getCurrentCsrfToken} to automatically handle wiping an invalid CSRF token. */
|
package/src/auth.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from './csrf-token.js';
|
|
17
17
|
import {AuthHeaderName} from './headers.js';
|
|
18
18
|
import {type ParseJwtParams} from './jwt/jwt.js';
|
|
19
|
+
import {type JwtUserData} from './jwt/user-jwt.js';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* All possible headers container types supported by {@link extractUserIdFromRequestHeaders}.
|
|
@@ -49,6 +50,11 @@ export type UserIdResult<UserId extends string | number> = {
|
|
|
49
50
|
userId: UserId;
|
|
50
51
|
jwtExpiration: FullDate<UtcTimezone>;
|
|
51
52
|
cookieName: string;
|
|
53
|
+
/**
|
|
54
|
+
* Unix timestamp (in milliseconds) when the session was originally started. Used to enforce max
|
|
55
|
+
* session duration.
|
|
56
|
+
*/
|
|
57
|
+
sessionStartedAt: JwtUserData['sessionStartedAt'];
|
|
52
58
|
};
|
|
53
59
|
|
|
54
60
|
function readCsrfTokenHeader(
|
|
@@ -100,6 +106,7 @@ export async function extractUserIdFromRequestHeaders<UserId extends string | nu
|
|
|
100
106
|
userId: jwt.data.userId as UserId,
|
|
101
107
|
jwtExpiration: jwt.jwtExpiration,
|
|
102
108
|
cookieName,
|
|
109
|
+
sessionStartedAt: jwt.data.sessionStartedAt,
|
|
103
110
|
};
|
|
104
111
|
} catch {
|
|
105
112
|
return undefined;
|
|
@@ -136,6 +143,7 @@ export async function insecureExtractUserIdFromCookieAlone<UserId extends string
|
|
|
136
143
|
userId: jwt.data.userId as UserId,
|
|
137
144
|
jwtExpiration: jwt.jwtExpiration,
|
|
138
145
|
cookieName,
|
|
146
|
+
sessionStartedAt: jwt.data.sessionStartedAt,
|
|
139
147
|
};
|
|
140
148
|
} catch {
|
|
141
149
|
return undefined;
|
|
@@ -156,6 +164,11 @@ export async function generateSuccessfulLoginHeaders<
|
|
|
156
164
|
overrides: PartialWithUndefined<{
|
|
157
165
|
csrfHeaderName: CsrfHeaderName;
|
|
158
166
|
}> = {},
|
|
167
|
+
/**
|
|
168
|
+
* The timestamp (in seconds) when the session originally started. If not provided, the current
|
|
169
|
+
* time will be used (for new sessions).
|
|
170
|
+
*/
|
|
171
|
+
sessionStartedAt?: number | undefined,
|
|
159
172
|
): Promise<
|
|
160
173
|
{
|
|
161
174
|
'set-cookie': string;
|
|
@@ -169,6 +182,7 @@ export async function generateSuccessfulLoginHeaders<
|
|
|
169
182
|
{
|
|
170
183
|
csrfToken: csrfToken.token,
|
|
171
184
|
userId,
|
|
185
|
+
sessionStartedAt: sessionStartedAt ?? Date.now(),
|
|
172
186
|
},
|
|
173
187
|
cookieConfig,
|
|
174
188
|
),
|
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
|
/**
|