auth-vir 2.7.2 → 3.0.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/README.md +33 -11
- package/dist/auth-client/backend-auth.client.d.ts +24 -15
- package/dist/auth-client/backend-auth.client.js +40 -61
- package/dist/auth-client/frontend-auth.client.d.ts +24 -11
- package/dist/auth-client/frontend-auth.client.js +41 -32
- package/dist/auth-client/is-session-refresh-ready.d.ts +23 -0
- package/dist/auth-client/is-session-refresh-ready.js +21 -0
- package/dist/auth.d.ts +11 -19
- package/dist/auth.js +30 -49
- package/dist/cookie.d.ts +11 -2
- package/dist/cookie.js +16 -12
- package/dist/csrf-token.d.ts +52 -12
- package/dist/csrf-token.js +40 -15
- package/dist/hash.js +6 -4
- package/dist/headers.d.ts +0 -1
- package/dist/headers.js +0 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/jwt/jwt.d.ts +11 -1
- package/dist/jwt/jwt.js +9 -2
- package/dist/jwt/user-jwt.js +2 -1
- package/package.json +11 -11
- package/src/auth-client/backend-auth.client.ts +74 -94
- package/src/auth-client/frontend-auth.client.ts +97 -73
- package/src/auth-client/is-session-refresh-ready.ts +40 -0
- package/src/auth.ts +53 -99
- package/src/cookie.ts +38 -16
- package/src/csrf-token.ts +109 -48
- package/src/hash.ts +7 -4
- package/src/headers.ts +0 -1
- package/src/index.ts +1 -1
- package/src/jwt/jwt.ts +27 -2
- package/src/jwt/user-jwt.ts +2 -1
- package/dist/log.d.ts +0 -12
- package/dist/log.js +0 -20
- package/src/log.ts +0 -22
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
import { HttpStatus, } from '@augment-vir/common';
|
|
2
2
|
import { listenToActivity } from 'detect-activity';
|
|
3
|
-
import { CsrfTokenFailureReason, extractCsrfTokenHeader, getCurrentCsrfToken, storeCsrfToken, wipeCurrentCsrfToken, } from '../csrf-token.js';
|
|
3
|
+
import { CsrfTokenFailureReason, defaultAllowedClockSkew, extractCsrfTokenHeader, getCurrentCsrfToken, resolveCsrfHeaderName, storeCsrfToken, wipeCurrentCsrfToken, } from '../csrf-token.js';
|
|
4
4
|
import { AuthHeaderName } from '../headers.js';
|
|
5
|
-
import { authLog } from '../log.js';
|
|
6
5
|
/**
|
|
7
6
|
* An auth client for sending and validating client requests to a backend. This should only be used
|
|
8
7
|
* in a frontend environment as it accesses native browser APIs.
|
|
9
8
|
*
|
|
10
9
|
* @category Auth : Client
|
|
11
|
-
* @category
|
|
10
|
+
* @category Clients
|
|
12
11
|
*/
|
|
13
12
|
export class FrontendAuthClient {
|
|
14
13
|
config;
|
|
15
14
|
userCheckInterval;
|
|
16
15
|
/** Used to clean up the activity listener on `.destroy()`. */
|
|
17
16
|
removeActivityListener;
|
|
18
|
-
constructor(config
|
|
17
|
+
constructor(config) {
|
|
19
18
|
this.config = config;
|
|
20
19
|
if (config.checkUser) {
|
|
21
20
|
this.removeActivityListener = listenToActivity({
|
|
@@ -41,19 +40,22 @@ export class FrontendAuthClient {
|
|
|
41
40
|
this.removeActivityListener?.();
|
|
42
41
|
}
|
|
43
42
|
/** Wraps {@link getCurrentCsrfToken} to automatically handle wiping an invalid CSRF token. */
|
|
44
|
-
|
|
45
|
-
const csrfTokenResult = getCurrentCsrfToken(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
43
|
+
getCurrentCsrfToken() {
|
|
44
|
+
const csrfTokenResult = getCurrentCsrfToken({
|
|
45
|
+
...this.config.csrf,
|
|
46
|
+
localStorage: this.config.overrides?.localStorage,
|
|
47
|
+
allowedClockSkew: this.config.allowedClockSkew || defaultAllowedClockSkew,
|
|
48
|
+
});
|
|
49
|
+
if (csrfTokenResult.failure) {
|
|
50
|
+
if (csrfTokenResult.failure !== CsrfTokenFailureReason.DoesNotExist) {
|
|
51
|
+
wipeCurrentCsrfToken({
|
|
52
|
+
...this.config.csrf,
|
|
53
|
+
localStorage: this.config.overrides?.localStorage,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
52
56
|
return undefined;
|
|
53
57
|
}
|
|
54
|
-
|
|
55
|
-
return csrfTokenResult.csrfToken?.token;
|
|
56
|
-
}
|
|
58
|
+
return csrfTokenResult.csrfToken.token;
|
|
57
59
|
}
|
|
58
60
|
/**
|
|
59
61
|
* Assume the given user. Pass `undefined` to wipe the currently assumed user.
|
|
@@ -62,7 +64,7 @@ export class FrontendAuthClient {
|
|
|
62
64
|
*/
|
|
63
65
|
async assumeUser(assumedUserParams) {
|
|
64
66
|
const localStorage = this.config.overrides?.localStorage || globalThis.localStorage;
|
|
65
|
-
const storageKey = this.config.
|
|
67
|
+
const storageKey = this.config.assumedUserHeaderName || AuthHeaderName.AssumedUser;
|
|
66
68
|
if (!assumedUserParams) {
|
|
67
69
|
localStorage.removeItem(storageKey);
|
|
68
70
|
return true;
|
|
@@ -75,7 +77,7 @@ export class FrontendAuthClient {
|
|
|
75
77
|
}
|
|
76
78
|
/** Gets the assumed user params stored in local storage, if any. */
|
|
77
79
|
getAssumedUser() {
|
|
78
|
-
const rawValue = (this.config.overrides?.localStorage || globalThis.localStorage).getItem(this.config.
|
|
80
|
+
const rawValue = (this.config.overrides?.localStorage || globalThis.localStorage).getItem(this.config.assumedUserHeaderName || AuthHeaderName.AssumedUser);
|
|
79
81
|
if (!rawValue) {
|
|
80
82
|
return undefined;
|
|
81
83
|
}
|
|
@@ -92,18 +94,18 @@ export class FrontendAuthClient {
|
|
|
92
94
|
* `@augment-vir/common`](https://electrovir.github.io/augment-vir/functions/mergeDeep.html) to
|
|
93
95
|
* combine them with these.
|
|
94
96
|
*/
|
|
95
|
-
|
|
96
|
-
const csrfToken =
|
|
97
|
+
createAuthenticatedRequestInit() {
|
|
98
|
+
const csrfToken = this.getCurrentCsrfToken();
|
|
97
99
|
const assumedUser = this.getAssumedUser();
|
|
98
100
|
const headers = {
|
|
99
101
|
...(csrfToken
|
|
100
102
|
? {
|
|
101
|
-
[
|
|
103
|
+
[resolveCsrfHeaderName(this.config.csrf)]: csrfToken,
|
|
102
104
|
}
|
|
103
105
|
: {}),
|
|
104
106
|
...(assumedUser
|
|
105
107
|
? {
|
|
106
|
-
[this.config.
|
|
108
|
+
[this.config.assumedUserHeaderName || AuthHeaderName.AssumedUser]: JSON.stringify(assumedUser),
|
|
107
109
|
}
|
|
108
110
|
: {}),
|
|
109
111
|
};
|
|
@@ -114,9 +116,11 @@ export class FrontendAuthClient {
|
|
|
114
116
|
}
|
|
115
117
|
/** Wipes the current user auth. */
|
|
116
118
|
async logout() {
|
|
117
|
-
authLog('auth-vir: LOGOUT - FrontendAuthClient.logout called', new Error().stack);
|
|
118
119
|
await this.config.authClearedCallback?.();
|
|
119
|
-
wipeCurrentCsrfToken(
|
|
120
|
+
wipeCurrentCsrfToken({
|
|
121
|
+
...this.config.csrf,
|
|
122
|
+
localStorage: this.config.overrides?.localStorage,
|
|
123
|
+
});
|
|
120
124
|
}
|
|
121
125
|
/**
|
|
122
126
|
* Use to handle a login response. Automatically stores the CSRF token.
|
|
@@ -126,17 +130,20 @@ export class FrontendAuthClient {
|
|
|
126
130
|
*/
|
|
127
131
|
async handleLoginResponse(response) {
|
|
128
132
|
if (!response.ok) {
|
|
129
|
-
authLog('auth-vir: LOGOUT - handleLoginResponse: response not ok');
|
|
130
133
|
await this.logout();
|
|
131
134
|
throw new Error('Login response failed.');
|
|
132
135
|
}
|
|
133
|
-
const { csrfToken } = extractCsrfTokenHeader(response, this.config.
|
|
136
|
+
const { csrfToken } = extractCsrfTokenHeader(response, this.config.csrf, {
|
|
137
|
+
allowedClockSkew: this.config.allowedClockSkew || defaultAllowedClockSkew,
|
|
138
|
+
});
|
|
134
139
|
if (!csrfToken) {
|
|
135
|
-
authLog('auth-vir: LOGOUT - handleLoginResponse: no CSRF token in response');
|
|
136
140
|
await this.logout();
|
|
137
141
|
throw new Error('Did not receive any CSRF token.');
|
|
138
142
|
}
|
|
139
|
-
storeCsrfToken(csrfToken,
|
|
143
|
+
storeCsrfToken(csrfToken, {
|
|
144
|
+
...this.config.csrf,
|
|
145
|
+
localStorage: this.config.overrides?.localStorage,
|
|
146
|
+
});
|
|
140
147
|
}
|
|
141
148
|
/**
|
|
142
149
|
* Use to verify _all_ responses received from the backend. Immediately logs the user out once
|
|
@@ -147,16 +154,18 @@ export class FrontendAuthClient {
|
|
|
147
154
|
async verifyResponseAuth(response) {
|
|
148
155
|
if (response.status === HttpStatus.Unauthorized &&
|
|
149
156
|
!response.headers?.get(AuthHeaderName.IsSignUpAuth)) {
|
|
150
|
-
authLog('auth-vir: LOGOUT - verifyResponseAuth: unauthorized response (401)', {
|
|
151
|
-
status: response.status,
|
|
152
|
-
});
|
|
153
157
|
await this.logout();
|
|
154
158
|
return false;
|
|
155
159
|
}
|
|
156
160
|
/** If the response has a new CSRF token, store it. */
|
|
157
|
-
const { csrfToken } = extractCsrfTokenHeader(response, this.config.
|
|
161
|
+
const { csrfToken } = extractCsrfTokenHeader(response, this.config.csrf, {
|
|
162
|
+
allowedClockSkew: this.config.allowedClockSkew || defaultAllowedClockSkew,
|
|
163
|
+
});
|
|
158
164
|
if (csrfToken) {
|
|
159
|
-
storeCsrfToken(csrfToken,
|
|
165
|
+
storeCsrfToken(csrfToken, {
|
|
166
|
+
...this.config.csrf,
|
|
167
|
+
localStorage: this.config.overrides?.localStorage,
|
|
168
|
+
});
|
|
160
169
|
}
|
|
161
170
|
return true;
|
|
162
171
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type AnyDuration, type FullDate, type UtcTimezone } from 'date-vir';
|
|
2
|
+
/**
|
|
3
|
+
* Determines if enough time has passed since the JWT was issued to start refreshing the session.
|
|
4
|
+
*
|
|
5
|
+
* Visually, this check looks like this:
|
|
6
|
+
*
|
|
7
|
+
* I====R===========E
|
|
8
|
+
*
|
|
9
|
+
* - I = JWT issued time (from the JWT's `iat` claim)
|
|
10
|
+
* - R = session refreshing is available now (I + sessionRefreshStartTime)
|
|
11
|
+
* - E = JWT expiration
|
|
12
|
+
* - `=` between R and E = the time frame in which the return value is `true`.
|
|
13
|
+
*
|
|
14
|
+
* @category Auth : Host
|
|
15
|
+
*/
|
|
16
|
+
export declare function isSessionRefreshReady({ now, jwtIssuedAt, sessionRefreshStartTime, }: {
|
|
17
|
+
/** The current time. */
|
|
18
|
+
now?: Readonly<FullDate<UtcTimezone>> | undefined;
|
|
19
|
+
/** When the JWT was issued (`iat` claim). */
|
|
20
|
+
jwtIssuedAt: Readonly<FullDate<UtcTimezone>>;
|
|
21
|
+
/** How long after JWT issuance before refreshing is available. */
|
|
22
|
+
sessionRefreshStartTime: Readonly<AnyDuration>;
|
|
23
|
+
}): boolean;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { calculateRelativeDate, getNowInUtcTimezone, isDateAfter, } from 'date-vir';
|
|
2
|
+
/**
|
|
3
|
+
* Determines if enough time has passed since the JWT was issued to start refreshing the session.
|
|
4
|
+
*
|
|
5
|
+
* Visually, this check looks like this:
|
|
6
|
+
*
|
|
7
|
+
* I====R===========E
|
|
8
|
+
*
|
|
9
|
+
* - I = JWT issued time (from the JWT's `iat` claim)
|
|
10
|
+
* - R = session refreshing is available now (I + sessionRefreshStartTime)
|
|
11
|
+
* - E = JWT expiration
|
|
12
|
+
* - `=` between R and E = the time frame in which the return value is `true`.
|
|
13
|
+
*
|
|
14
|
+
* @category Auth : Host
|
|
15
|
+
*/
|
|
16
|
+
export function isSessionRefreshReady({ now = getNowInUtcTimezone(), jwtIssuedAt, sessionRefreshStartTime, }) {
|
|
17
|
+
return isDateAfter({
|
|
18
|
+
fullDate: now,
|
|
19
|
+
relativeTo: calculateRelativeDate(jwtIssuedAt, sessionRefreshStartTime),
|
|
20
|
+
});
|
|
21
|
+
}
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type PartialWithUndefined } from '@augment-vir/common';
|
|
2
2
|
import { type FullDate, type UtcTimezone } from 'date-vir';
|
|
3
3
|
import { type CookieParams } from './cookie.js';
|
|
4
|
-
import {
|
|
4
|
+
import { type CsrfHeaderNameOption } from './csrf-token.js';
|
|
5
5
|
import { type ParseJwtParams } from './jwt/jwt.js';
|
|
6
6
|
import { type JwtUserData } from './jwt/user-jwt.js';
|
|
7
7
|
/**
|
|
@@ -18,7 +18,11 @@ export type HeaderContainer = Record<string, string[] | undefined | string | num
|
|
|
18
18
|
export type UserIdResult<UserId extends string | number> = {
|
|
19
19
|
userId: UserId;
|
|
20
20
|
jwtExpiration: FullDate<UtcTimezone>;
|
|
21
|
+
/** When the JWT was issued (`iat` claim). */
|
|
22
|
+
jwtIssuedAt: FullDate<UtcTimezone>;
|
|
21
23
|
cookieName: string;
|
|
24
|
+
/** The CSRF token embedded in the JWT. */
|
|
25
|
+
csrfToken: string;
|
|
22
26
|
/**
|
|
23
27
|
* Unix timestamp (in milliseconds) when the session was originally started. Used to enforce max
|
|
24
28
|
* session duration.
|
|
@@ -33,9 +37,7 @@ export type UserIdResult<UserId extends string | number> = {
|
|
|
33
37
|
* @category Auth : Host
|
|
34
38
|
* @returns The extracted user id or `undefined` if no valid auth headers exist.
|
|
35
39
|
*/
|
|
36
|
-
export declare function extractUserIdFromRequestHeaders<UserId extends string | number>(headers: HeaderContainer, jwtParams: Readonly<ParseJwtParams>, cookieName?: string
|
|
37
|
-
csrfHeaderName: string;
|
|
38
|
-
}>): Promise<Readonly<UserIdResult<UserId>> | undefined>;
|
|
40
|
+
export declare function extractUserIdFromRequestHeaders<UserId extends string | number>(headers: HeaderContainer, jwtParams: Readonly<ParseJwtParams>, csrfHeaderNameOption: Readonly<CsrfHeaderNameOption>, cookieName?: string): Promise<Readonly<UserIdResult<UserId>> | undefined>;
|
|
39
41
|
/**
|
|
40
42
|
* Extract a user id from just the cookie, without CSRF token validation. This is _less secure_ than
|
|
41
43
|
* {@link extractUserIdFromRequestHeaders} as a result. This should only be used in rare
|
|
@@ -50,29 +52,21 @@ export declare function insecureExtractUserIdFromCookieAlone<UserId extends stri
|
|
|
50
52
|
*
|
|
51
53
|
* @category Auth : Host
|
|
52
54
|
*/
|
|
53
|
-
export declare function generateSuccessfulLoginHeaders
|
|
55
|
+
export declare function generateSuccessfulLoginHeaders(
|
|
54
56
|
/** The id from your database of the user you're authenticating. */
|
|
55
|
-
userId: string | number, cookieConfig: Readonly<CookieParams>,
|
|
56
|
-
csrfHeaderName: CsrfHeaderName;
|
|
57
|
-
}>,
|
|
57
|
+
userId: string | number, cookieConfig: Readonly<CookieParams>, csrfHeaderNameOption: Readonly<CsrfHeaderNameOption>,
|
|
58
58
|
/**
|
|
59
59
|
* The timestamp (in seconds) when the session originally started. If not provided, the current
|
|
60
60
|
* time will be used (for new sessions).
|
|
61
61
|
*/
|
|
62
|
-
sessionStartedAt?: number | undefined): Promise<
|
|
63
|
-
'set-cookie': string;
|
|
64
|
-
} & Record<CsrfHeaderName, string>>;
|
|
62
|
+
sessionStartedAt?: number | undefined): Promise<Record<string, string>>;
|
|
65
63
|
/**
|
|
66
64
|
* Used by host (backend) code to set headers on a response object when the user has logged out or
|
|
67
65
|
* failed to authorize.
|
|
68
66
|
*
|
|
69
67
|
* @category Auth : Host
|
|
70
68
|
*/
|
|
71
|
-
export declare function generateLogoutHeaders
|
|
72
|
-
csrfHeaderName: CsrfHeaderName;
|
|
73
|
-
}>): {
|
|
74
|
-
'set-cookie': string;
|
|
75
|
-
} & Record<CsrfHeaderName, string>;
|
|
69
|
+
export declare function generateLogoutHeaders(cookieConfig: Readonly<Pick<CookieParams, 'cookieName' | 'hostOrigin' | 'isDev'>>, csrfHeaderNameOption: Readonly<CsrfHeaderNameOption>): Record<string, string>;
|
|
76
70
|
/**
|
|
77
71
|
* Store auth data on a client (frontend) after receiving an auth response from the host (backend).
|
|
78
72
|
* Specifically, this stores the CSRF token into local storage (which doesn't need to be a secret).
|
|
@@ -82,13 +76,11 @@ export declare function generateLogoutHeaders<CsrfHeaderName extends string = Au
|
|
|
82
76
|
* @category Auth : Client
|
|
83
77
|
* @throws Error if no CSRF token header is found.
|
|
84
78
|
*/
|
|
85
|
-
export declare function handleAuthResponse(response: Readonly<Pick<Response, 'ok' | 'headers'>>,
|
|
79
|
+
export declare function handleAuthResponse(response: Readonly<Pick<Response, 'ok' | 'headers'>>, options: Readonly<CsrfHeaderNameOption> & PartialWithUndefined<{
|
|
86
80
|
/**
|
|
87
81
|
* Allows mocking or overriding the global `localStorage`.
|
|
88
82
|
*
|
|
89
83
|
* @default globalThis.localStorage
|
|
90
84
|
*/
|
|
91
85
|
localStorage: Pick<Storage, 'setItem' | 'removeItem'>;
|
|
92
|
-
/** Override the default CSRF token header name. */
|
|
93
|
-
csrfHeaderName: string;
|
|
94
86
|
}>): void;
|
package/dist/auth.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { AuthCookieName, clearAuthCookie, extractCookieJwt, generateAuthCookie, } from './cookie.js';
|
|
2
|
-
import { extractCsrfTokenHeader, generateCsrfToken, parseCsrfToken, storeCsrfToken, wipeCurrentCsrfToken, } from './csrf-token.js';
|
|
3
|
-
import { AuthHeaderName } from './headers.js';
|
|
4
|
-
import { authLog } from './log.js';
|
|
2
|
+
import { extractCsrfTokenHeader, generateCsrfToken, parseCsrfToken, resolveCsrfHeaderName, storeCsrfToken, wipeCurrentCsrfToken, } from './csrf-token.js';
|
|
5
3
|
function readHeader(headers, headerName) {
|
|
6
4
|
if (headers instanceof Headers) {
|
|
7
5
|
return headers.get(headerName) || undefined;
|
|
@@ -19,15 +17,12 @@ function readHeader(headers, headerName) {
|
|
|
19
17
|
}
|
|
20
18
|
}
|
|
21
19
|
}
|
|
22
|
-
function readCsrfTokenHeader(headers,
|
|
23
|
-
const rawCsrfToken = readHeader(headers,
|
|
20
|
+
function readCsrfTokenHeader(headers, csrfHeaderNameOption) {
|
|
21
|
+
const rawCsrfToken = readHeader(headers, resolveCsrfHeaderName(csrfHeaderNameOption));
|
|
24
22
|
if (!rawCsrfToken) {
|
|
25
23
|
return undefined;
|
|
26
24
|
}
|
|
27
25
|
const token = parseCsrfToken(rawCsrfToken).csrfToken?.token || rawCsrfToken;
|
|
28
|
-
if (!token) {
|
|
29
|
-
authLog('auth-vir: CSRF token not found.');
|
|
30
|
-
}
|
|
31
26
|
return token;
|
|
32
27
|
}
|
|
33
28
|
/**
|
|
@@ -38,38 +33,27 @@ function readCsrfTokenHeader(headers, overrides) {
|
|
|
38
33
|
* @category Auth : Host
|
|
39
34
|
* @returns The extracted user id or `undefined` if no valid auth headers exist.
|
|
40
35
|
*/
|
|
41
|
-
export async function extractUserIdFromRequestHeaders(headers, jwtParams, cookieName = AuthCookieName.Auth
|
|
36
|
+
export async function extractUserIdFromRequestHeaders(headers, jwtParams, csrfHeaderNameOption, cookieName = AuthCookieName.Auth) {
|
|
42
37
|
try {
|
|
43
|
-
const csrfToken = readCsrfTokenHeader(headers,
|
|
38
|
+
const csrfToken = readCsrfTokenHeader(headers, csrfHeaderNameOption);
|
|
44
39
|
const cookie = readHeader(headers, 'cookie');
|
|
45
40
|
if (!cookie || !csrfToken) {
|
|
46
|
-
authLog('auth-vir: extractUserIdFromRequestHeaders failed - missing cookie or CSRF token', {
|
|
47
|
-
hasCookie: !!cookie,
|
|
48
|
-
hasCsrfToken: !!csrfToken,
|
|
49
|
-
cookieName,
|
|
50
|
-
});
|
|
51
41
|
return undefined;
|
|
52
42
|
}
|
|
53
43
|
const jwt = await extractCookieJwt(cookie, jwtParams, cookieName);
|
|
54
44
|
if (!jwt || jwt.data.csrfToken !== csrfToken) {
|
|
55
|
-
if (cookieName === AuthCookieName.Auth) {
|
|
56
|
-
authLog('auth-vir: extractUserIdFromRequestHeaders failed - JWT invalid or CSRF mismatch', {
|
|
57
|
-
hasJwt: !!jwt,
|
|
58
|
-
csrfMatch: jwt ? jwt.data.csrfToken === csrfToken : false,
|
|
59
|
-
cookieName,
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
45
|
return undefined;
|
|
63
46
|
}
|
|
64
47
|
return {
|
|
65
48
|
userId: jwt.data.userId,
|
|
66
49
|
jwtExpiration: jwt.jwtExpiration,
|
|
50
|
+
jwtIssuedAt: jwt.jwtIssuedAt,
|
|
67
51
|
cookieName,
|
|
52
|
+
csrfToken: jwt.data.csrfToken,
|
|
68
53
|
sessionStartedAt: jwt.data.sessionStartedAt,
|
|
69
54
|
};
|
|
70
55
|
}
|
|
71
|
-
catch
|
|
72
|
-
authLog('auth-vir: extractUserIdFromRequestHeaders error', { error, cookieName });
|
|
56
|
+
catch {
|
|
73
57
|
return undefined;
|
|
74
58
|
}
|
|
75
59
|
}
|
|
@@ -85,23 +69,22 @@ export async function insecureExtractUserIdFromCookieAlone(headers, jwtParams, c
|
|
|
85
69
|
try {
|
|
86
70
|
const cookie = readHeader(headers, 'cookie');
|
|
87
71
|
if (!cookie) {
|
|
88
|
-
authLog('auth-vir: insecureExtractUserIdFromCookieAlone failed - no cookie');
|
|
89
72
|
return undefined;
|
|
90
73
|
}
|
|
91
74
|
const jwt = await extractCookieJwt(cookie, jwtParams, cookieName);
|
|
92
75
|
if (!jwt) {
|
|
93
|
-
authLog('auth-vir: insecureExtractUserIdFromCookieAlone failed - JWT extraction failed');
|
|
94
76
|
return undefined;
|
|
95
77
|
}
|
|
96
78
|
return {
|
|
97
79
|
userId: jwt.data.userId,
|
|
98
80
|
jwtExpiration: jwt.jwtExpiration,
|
|
81
|
+
jwtIssuedAt: jwt.jwtIssuedAt,
|
|
99
82
|
cookieName,
|
|
83
|
+
csrfToken: jwt.data.csrfToken,
|
|
100
84
|
sessionStartedAt: jwt.data.sessionStartedAt,
|
|
101
85
|
};
|
|
102
86
|
}
|
|
103
|
-
catch
|
|
104
|
-
authLog('auth-vir: insecureExtractUserIdFromCookieAlone error', { error });
|
|
87
|
+
catch {
|
|
105
88
|
return undefined;
|
|
106
89
|
}
|
|
107
90
|
}
|
|
@@ -112,21 +95,25 @@ export async function insecureExtractUserIdFromCookieAlone(headers, jwtParams, c
|
|
|
112
95
|
*/
|
|
113
96
|
export async function generateSuccessfulLoginHeaders(
|
|
114
97
|
/** The id from your database of the user you're authenticating. */
|
|
115
|
-
userId, cookieConfig,
|
|
98
|
+
userId, cookieConfig, csrfHeaderNameOption,
|
|
116
99
|
/**
|
|
117
100
|
* The timestamp (in seconds) when the session originally started. If not provided, the current
|
|
118
101
|
* time will be used (for new sessions).
|
|
119
102
|
*/
|
|
120
103
|
sessionStartedAt) {
|
|
121
104
|
const csrfToken = generateCsrfToken(cookieConfig.cookieDuration);
|
|
122
|
-
const csrfHeaderName = (
|
|
105
|
+
const csrfHeaderName = resolveCsrfHeaderName(csrfHeaderNameOption);
|
|
106
|
+
const { cookie, expiration } = await generateAuthCookie({
|
|
107
|
+
csrfToken: csrfToken.token,
|
|
108
|
+
userId,
|
|
109
|
+
sessionStartedAt: sessionStartedAt ?? Date.now(),
|
|
110
|
+
}, cookieConfig);
|
|
123
111
|
return {
|
|
124
|
-
'set-cookie':
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
[csrfHeaderName]: JSON.stringify(csrfToken),
|
|
112
|
+
'set-cookie': cookie,
|
|
113
|
+
[csrfHeaderName]: JSON.stringify({
|
|
114
|
+
token: csrfToken.token,
|
|
115
|
+
expiration,
|
|
116
|
+
}),
|
|
130
117
|
};
|
|
131
118
|
}
|
|
132
119
|
/**
|
|
@@ -135,11 +122,8 @@ sessionStartedAt) {
|
|
|
135
122
|
*
|
|
136
123
|
* @category Auth : Host
|
|
137
124
|
*/
|
|
138
|
-
export function generateLogoutHeaders(cookieConfig,
|
|
139
|
-
|
|
140
|
-
cookieName: cookieConfig.cookieName,
|
|
141
|
-
}, new Error().stack);
|
|
142
|
-
const csrfHeaderName = (overrides.csrfHeaderName || AuthHeaderName.CsrfToken);
|
|
125
|
+
export function generateLogoutHeaders(cookieConfig, csrfHeaderNameOption) {
|
|
126
|
+
const csrfHeaderName = resolveCsrfHeaderName(csrfHeaderNameOption);
|
|
143
127
|
return {
|
|
144
128
|
'set-cookie': clearAuthCookie(cookieConfig),
|
|
145
129
|
[csrfHeaderName]: 'redacted',
|
|
@@ -154,18 +138,15 @@ export function generateLogoutHeaders(cookieConfig, overrides = {}) {
|
|
|
154
138
|
* @category Auth : Client
|
|
155
139
|
* @throws Error if no CSRF token header is found.
|
|
156
140
|
*/
|
|
157
|
-
export function handleAuthResponse(response,
|
|
141
|
+
export function handleAuthResponse(response, options) {
|
|
158
142
|
if (!response.ok) {
|
|
159
|
-
|
|
160
|
-
wipeCurrentCsrfToken(overrides);
|
|
143
|
+
wipeCurrentCsrfToken(options);
|
|
161
144
|
return;
|
|
162
145
|
}
|
|
163
|
-
const { csrfToken } = extractCsrfTokenHeader(response,
|
|
146
|
+
const { csrfToken } = extractCsrfTokenHeader(response, options);
|
|
164
147
|
if (!csrfToken) {
|
|
165
|
-
|
|
166
|
-
wipeCurrentCsrfToken(overrides);
|
|
148
|
+
wipeCurrentCsrfToken(options);
|
|
167
149
|
throw new Error('Did not receive any CSRF token.');
|
|
168
150
|
}
|
|
169
|
-
|
|
170
|
-
storeCsrfToken(csrfToken, overrides);
|
|
151
|
+
storeCsrfToken(csrfToken, options);
|
|
171
152
|
}
|
package/dist/cookie.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type PartialWithUndefined } from '@augment-vir/common';
|
|
2
|
-
import { type AnyDuration } from 'date-vir';
|
|
2
|
+
import { type AnyDuration, type FullDate, type UtcTimezone } from 'date-vir';
|
|
3
3
|
import { type Primitive } from 'type-fest';
|
|
4
4
|
import { type CreateJwtParams, type ParseJwtParams, type ParsedJwt } from './jwt/jwt.js';
|
|
5
5
|
import { type JwtUserData } from './jwt/user-jwt.js';
|
|
@@ -48,12 +48,21 @@ export type CookieParams = {
|
|
|
48
48
|
*/
|
|
49
49
|
isDev: boolean;
|
|
50
50
|
}>;
|
|
51
|
+
/**
|
|
52
|
+
* Output from {@link generateAuthCookie}.
|
|
53
|
+
*
|
|
54
|
+
* @category Internal
|
|
55
|
+
*/
|
|
56
|
+
export type GenerateAuthCookieResult = {
|
|
57
|
+
cookie: string;
|
|
58
|
+
expiration: FullDate<UtcTimezone>;
|
|
59
|
+
};
|
|
51
60
|
/**
|
|
52
61
|
* Generate a secure cookie that stores the user JWT data. Used in host (backend) code.
|
|
53
62
|
*
|
|
54
63
|
* @category Internal
|
|
55
64
|
*/
|
|
56
|
-
export declare function generateAuthCookie(userJwtData: Readonly<JwtUserData>, cookieConfig: Readonly<CookieParams>): Promise<
|
|
65
|
+
export declare function generateAuthCookie(userJwtData: Readonly<JwtUserData>, cookieConfig: Readonly<CookieParams>): Promise<GenerateAuthCookieResult>;
|
|
57
66
|
/**
|
|
58
67
|
* Generate a cookie value that will clear the previous auth cookie. Use this when signing out.
|
|
59
68
|
*
|
package/dist/cookie.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { check } from '@augment-vir/assert';
|
|
2
|
-
import { safeMatch } from '@augment-vir/common';
|
|
3
|
-
import { convertDuration } from 'date-vir';
|
|
2
|
+
import { escapeStringForRegExp, safeMatch } from '@augment-vir/common';
|
|
3
|
+
import { calculateRelativeDate, convertDuration, getNowInUtcTimezone, } from 'date-vir';
|
|
4
4
|
import { parseUrl } from 'url-vir';
|
|
5
5
|
import { createUserJwt, parseUserJwt } from './jwt/user-jwt.js';
|
|
6
6
|
/**
|
|
@@ -21,15 +21,19 @@ export var AuthCookieName;
|
|
|
21
21
|
* @category Internal
|
|
22
22
|
*/
|
|
23
23
|
export async function generateAuthCookie(userJwtData, cookieConfig) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
24
|
+
const expiration = calculateRelativeDate(getNowInUtcTimezone(), cookieConfig.cookieDuration);
|
|
25
|
+
return {
|
|
26
|
+
cookie: generateCookie({
|
|
27
|
+
[cookieConfig.cookieName || 'auth']: await createUserJwt(userJwtData, cookieConfig.jwtParams),
|
|
28
|
+
Domain: parseUrl(cookieConfig.hostOrigin).hostname,
|
|
29
|
+
HttpOnly: true,
|
|
30
|
+
Path: '/',
|
|
31
|
+
SameSite: 'Strict',
|
|
32
|
+
'MAX-AGE': convertDuration(cookieConfig.cookieDuration, { seconds: true }).seconds,
|
|
33
|
+
Secure: !cookieConfig.isDev,
|
|
34
|
+
}),
|
|
35
|
+
expiration,
|
|
36
|
+
};
|
|
33
37
|
}
|
|
34
38
|
/**
|
|
35
39
|
* Generate a cookie value that will clear the previous auth cookie. Use this when signing out.
|
|
@@ -78,7 +82,7 @@ export function generateCookie(params) {
|
|
|
78
82
|
* @returns The extracted auth Cookie JWT data or `undefined` if no valid auth JWT data was found.
|
|
79
83
|
*/
|
|
80
84
|
export async function extractCookieJwt(rawCookie, jwtParams, cookieName = AuthCookieName.Auth) {
|
|
81
|
-
const cookieRegExp = new RegExp(`${cookieName}=[^;]+(?:;|$)`);
|
|
85
|
+
const cookieRegExp = new RegExp(`${escapeStringForRegExp(cookieName)}=[^;]+(?:;|$)`);
|
|
82
86
|
const [cookieValue] = safeMatch(rawCookie, cookieRegExp);
|
|
83
87
|
if (!cookieValue) {
|
|
84
88
|
return undefined;
|
package/dist/csrf-token.d.ts
CHANGED
|
@@ -27,6 +27,14 @@ export declare const csrfTokenShape: import("object-shape-tester").Shape<{
|
|
|
27
27
|
* @category Internal
|
|
28
28
|
*/
|
|
29
29
|
export type CsrfToken = typeof csrfTokenShape.runtimeType;
|
|
30
|
+
/**
|
|
31
|
+
* Default allowed clock skew for CSRF token expiration checks. Accounts for differences between
|
|
32
|
+
* server and client clocks when checking token expiration.
|
|
33
|
+
*
|
|
34
|
+
* @category Internal
|
|
35
|
+
* @default {minutes: 5}
|
|
36
|
+
*/
|
|
37
|
+
export declare const defaultAllowedClockSkew: Readonly<AnyDuration>;
|
|
30
38
|
/**
|
|
31
39
|
* Generates a random, cryptographically secure CSRF token.
|
|
32
40
|
*
|
|
@@ -48,6 +56,25 @@ export declare enum CsrfTokenFailureReason {
|
|
|
48
56
|
/** A CSRF token was found and parsed but is expired. */
|
|
49
57
|
Expired = "expired"
|
|
50
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Options for specifying the CSRF token header name.
|
|
61
|
+
*
|
|
62
|
+
* @category Auth : Client
|
|
63
|
+
* @category Auth : Host
|
|
64
|
+
*/
|
|
65
|
+
export type CsrfHeaderNameOption = RequireExactlyOne<{
|
|
66
|
+
/** Prefix used to generate the header name: `${prefix}-auth-vir-csrf-token`. */
|
|
67
|
+
csrfHeaderPrefix: string;
|
|
68
|
+
/** Overrides the entire CSRF header name. */
|
|
69
|
+
csrfHeaderName: string;
|
|
70
|
+
}>;
|
|
71
|
+
/**
|
|
72
|
+
* Resolves a {@link CsrfHeaderNameOption} to the actual header name string.
|
|
73
|
+
*
|
|
74
|
+
* @category Auth : Client
|
|
75
|
+
* @category Auth : Host
|
|
76
|
+
*/
|
|
77
|
+
export declare function resolveCsrfHeaderName(option: Readonly<CsrfHeaderNameOption>): string;
|
|
51
78
|
/**
|
|
52
79
|
* Output from {@link getCurrentCsrfToken}.
|
|
53
80
|
*
|
|
@@ -64,45 +91,60 @@ export type GetCsrfTokenResult = RequireExactlyOne<{
|
|
|
64
91
|
*/
|
|
65
92
|
export declare function extractCsrfTokenHeader(response: Readonly<PartialWithUndefined<SelectFrom<Response, {
|
|
66
93
|
headers: true;
|
|
67
|
-
}>>>,
|
|
68
|
-
|
|
94
|
+
}>>>, csrfHeaderNameOption: Readonly<CsrfHeaderNameOption>, options?: PartialWithUndefined<{
|
|
95
|
+
/**
|
|
96
|
+
* Allowed clock skew tolerance for CSRF token expiration checks.
|
|
97
|
+
*
|
|
98
|
+
* @default {minutes: 5}
|
|
99
|
+
*/
|
|
100
|
+
allowedClockSkew: Readonly<AnyDuration>;
|
|
69
101
|
}>): Readonly<GetCsrfTokenResult>;
|
|
70
102
|
/**
|
|
71
103
|
* Stores the given CSRF token into local storage.
|
|
72
104
|
*
|
|
73
105
|
* @category Auth : Client
|
|
74
106
|
*/
|
|
75
|
-
export declare function storeCsrfToken(csrfToken: Readonly<CsrfToken>,
|
|
107
|
+
export declare function storeCsrfToken(csrfToken: Readonly<CsrfToken>, options: Readonly<CsrfHeaderNameOption> & PartialWithUndefined<{
|
|
76
108
|
/**
|
|
77
109
|
* Allows mocking or overriding the global `localStorage`.
|
|
78
110
|
*
|
|
79
111
|
* @default globalThis.localStorage
|
|
80
112
|
*/
|
|
81
113
|
localStorage: Pick<Storage, 'setItem' | 'removeItem'>;
|
|
82
|
-
/** Override the default CSRF token header name. */
|
|
83
|
-
csrfHeaderName: string;
|
|
84
114
|
}>): void;
|
|
85
115
|
/**
|
|
86
116
|
* Parse a raw CSRF token JSON string.
|
|
87
117
|
*
|
|
88
118
|
* @category Internal
|
|
89
119
|
*/
|
|
90
|
-
export declare function parseCsrfToken(value: string | undefined | null
|
|
120
|
+
export declare function parseCsrfToken(value: string | undefined | null, options?: PartialWithUndefined<{
|
|
121
|
+
/**
|
|
122
|
+
* Allowed clock skew tolerance for CSRF token expiration checks. Accounts for differences
|
|
123
|
+
* between server and client clocks.
|
|
124
|
+
*
|
|
125
|
+
* @default {minutes: 5}
|
|
126
|
+
*/
|
|
127
|
+
allowedClockSkew: Readonly<AnyDuration>;
|
|
128
|
+
}>): Readonly<GetCsrfTokenResult>;
|
|
91
129
|
/**
|
|
92
130
|
* Used in client (frontend) code to retrieve the current CSRF token in order to send it with
|
|
93
131
|
* requests to the host (backend).
|
|
94
132
|
*
|
|
95
133
|
* @category Auth : Client
|
|
96
134
|
*/
|
|
97
|
-
export declare function getCurrentCsrfToken(
|
|
135
|
+
export declare function getCurrentCsrfToken(options: Readonly<CsrfHeaderNameOption> & PartialWithUndefined<{
|
|
98
136
|
/**
|
|
99
137
|
* Allows mocking or overriding the global `localStorage`.
|
|
100
138
|
*
|
|
101
139
|
* @default globalThis.localStorage
|
|
102
140
|
*/
|
|
103
141
|
localStorage: Pick<Storage, 'getItem'>;
|
|
104
|
-
/**
|
|
105
|
-
|
|
142
|
+
/**
|
|
143
|
+
* Allowed clock skew tolerance for CSRF token expiration checks.
|
|
144
|
+
*
|
|
145
|
+
* @default {minutes: 5}
|
|
146
|
+
*/
|
|
147
|
+
allowedClockSkew: Readonly<AnyDuration>;
|
|
106
148
|
}>): Readonly<GetCsrfTokenResult>;
|
|
107
149
|
/**
|
|
108
150
|
* Wipes the current stored CSRF token. This should be used by client (frontend) code to logout a
|
|
@@ -110,13 +152,11 @@ export declare function getCurrentCsrfToken(overrides?: PartialWithUndefined<{
|
|
|
110
152
|
*
|
|
111
153
|
* @category Auth : Client
|
|
112
154
|
*/
|
|
113
|
-
export declare function wipeCurrentCsrfToken(
|
|
155
|
+
export declare function wipeCurrentCsrfToken(options: Readonly<CsrfHeaderNameOption> & PartialWithUndefined<{
|
|
114
156
|
/**
|
|
115
157
|
* Allows mocking or overriding the global `localStorage`.
|
|
116
158
|
*
|
|
117
159
|
* @default globalThis.localStorage
|
|
118
160
|
*/
|
|
119
161
|
localStorage: Pick<Storage, 'removeItem'>;
|
|
120
|
-
/** Override the default CSRF token header name. */
|
|
121
|
-
csrfHeaderName: string;
|
|
122
162
|
}>): void;
|