auth-vir 1.3.1 → 2.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 +27 -18
- package/dist/auth-client/backend-auth.client.d.ts +177 -0
- package/dist/auth-client/backend-auth.client.js +232 -0
- package/dist/auth-client/frontend-auth.client.d.ts +64 -0
- package/dist/auth-client/frontend-auth.client.js +107 -0
- package/dist/auth.d.ts +33 -47
- package/dist/auth.js +36 -36
- package/dist/cookie.d.ts +15 -4
- package/dist/cookie.js +17 -5
- package/dist/csrf-token.d.ts +113 -3
- package/dist/csrf-token.js +101 -5
- package/dist/generated/browser.d.ts +9 -0
- package/dist/generated/browser.js +16 -0
- package/dist/generated/client.d.ts +26 -0
- package/dist/generated/client.js +31 -0
- package/dist/generated/commonInputTypes.d.ts +122 -0
- package/dist/generated/enums.d.ts +1 -0
- package/dist/generated/enums.js +9 -0
- package/dist/generated/internal/class.d.ts +126 -0
- package/dist/generated/internal/class.js +84 -0
- package/dist/generated/internal/prismaNamespace.d.ts +544 -0
- package/dist/generated/internal/prismaNamespace.js +101 -0
- package/dist/generated/internal/prismaNamespaceBrowser.d.ts +75 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js +69 -0
- package/dist/generated/models/User.d.ts +983 -0
- package/dist/generated/models/User.js +1 -0
- package/dist/generated/models.d.ts +2 -0
- package/dist/generated/models.js +1 -0
- package/dist/generated/shapes.gen.d.ts +8 -0
- package/dist/generated/shapes.gen.js +11 -0
- package/dist/headers.d.ts +20 -0
- package/dist/headers.js +33 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +5 -3
- package/dist/jwt/jwt-keys.script.d.ts +1 -0
- package/dist/{jwt.d.ts → jwt/jwt.d.ts} +11 -2
- package/dist/{jwt.js → jwt/jwt.js} +14 -3
- package/dist/{user-jwt.d.ts → jwt/user-jwt.d.ts} +8 -8
- package/dist/{user-jwt.js → jwt/user-jwt.js} +12 -9
- package/package.json +17 -12
- package/src/auth-client/backend-auth.client.ts +500 -0
- package/src/auth-client/frontend-auth.client.ts +182 -0
- package/src/auth.ts +100 -78
- package/src/cookie.ts +20 -8
- package/src/csrf-token.ts +196 -5
- package/src/generated/browser.ts +23 -0
- package/src/generated/client.ts +47 -0
- package/src/generated/commonInputTypes.ts +147 -0
- package/src/generated/enums.ts +14 -0
- package/src/generated/internal/class.ts +236 -0
- package/src/generated/internal/prismaNamespace.ts +761 -0
- package/src/generated/internal/prismaNamespaceBrowser.ts +102 -0
- package/src/generated/models/User.ts +1135 -0
- package/src/generated/models.ts +11 -0
- package/src/generated/shapes.gen.ts +15 -0
- package/src/headers.ts +35 -0
- package/src/index.ts +5 -3
- package/src/{jwt.ts → jwt/jwt.ts} +34 -5
- package/src/{user-jwt.ts → jwt/user-jwt.ts} +22 -13
- /package/dist/{jwt-keys.script.d.ts → generated/commonInputTypes.js} +0 -0
- /package/dist/{jwt-keys.d.ts → jwt/jwt-keys.d.ts} +0 -0
- /package/dist/{jwt-keys.js → jwt/jwt-keys.js} +0 -0
- /package/dist/{jwt-keys.script.js → jwt/jwt-keys.script.js} +0 -0
- /package/src/{jwt-keys.script.ts → jwt/jwt-keys.script.ts} +0 -0
- /package/src/{jwt-keys.ts → jwt/jwt-keys.ts} +0 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ensureArray,
|
|
3
|
+
type AnyObject,
|
|
4
|
+
type JsonCompatibleObject,
|
|
5
|
+
type MaybePromise,
|
|
6
|
+
type PartialWithUndefined,
|
|
7
|
+
type RequiredAndNotNull,
|
|
8
|
+
} from '@augment-vir/common';
|
|
9
|
+
import {calculateRelativeDate, getNowInUtcTimezone, isDateAfter, type AnyDuration} from 'date-vir';
|
|
10
|
+
import {type IncomingHttpHeaders, type OutgoingHttpHeaders} from 'node:http';
|
|
11
|
+
import {type EmptyObject} from 'type-fest';
|
|
12
|
+
import {
|
|
13
|
+
extractUserIdFromRequestHeaders,
|
|
14
|
+
generateLogoutHeaders,
|
|
15
|
+
generateSuccessfulLoginHeaders,
|
|
16
|
+
insecureExtractUserIdFromCookieAlone,
|
|
17
|
+
type UserIdResult,
|
|
18
|
+
} from '../auth.js';
|
|
19
|
+
import {AuthCookieName, type CookieParams} from '../cookie.js';
|
|
20
|
+
import {AuthHeaderName, mergeHeaderValues} from '../headers.js';
|
|
21
|
+
import {generateNewJwtKeys, parseJwtKeys, type JwtKeys, type RawJwtKeys} from '../jwt/jwt-keys.js';
|
|
22
|
+
import {type CreateJwtParams} from '../jwt/jwt.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Output from `BackendAuthClient.getSecureUser()`.
|
|
26
|
+
*
|
|
27
|
+
* @category Internal
|
|
28
|
+
*/
|
|
29
|
+
export type GetUserResult<DatabaseUser extends AnyObject> = {
|
|
30
|
+
/** The retrieved user. */
|
|
31
|
+
user: DatabaseUser;
|
|
32
|
+
/**
|
|
33
|
+
* When `true`, indicates that the current `user` result is as assumed user. This can only be
|
|
34
|
+
* `true` if you've configured user assuming in `BackendAuthClient`.
|
|
35
|
+
*/
|
|
36
|
+
isAssumed: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* This should be merged into your own response headers. It usually contains auth cookie
|
|
39
|
+
* duration refresh headers.
|
|
40
|
+
*/
|
|
41
|
+
responseHeaders: OutgoingHttpHeaders;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Config for {@link BackendAuthClient}.
|
|
46
|
+
*
|
|
47
|
+
* @category Internal
|
|
48
|
+
*/
|
|
49
|
+
export type BackendAuthClientConfig<
|
|
50
|
+
DatabaseUser extends AnyObject,
|
|
51
|
+
UserId extends string | number,
|
|
52
|
+
CsrfHeaderName extends string = AuthHeaderName.CsrfToken,
|
|
53
|
+
AssumedUserParams extends JsonCompatibleObject = EmptyObject,
|
|
54
|
+
> = Readonly<
|
|
55
|
+
{
|
|
56
|
+
/** The origin of your backend that is offering auth cookies. */
|
|
57
|
+
serviceOrigin: string;
|
|
58
|
+
/** Finds the relevant user from your own database. */
|
|
59
|
+
getUserFromDatabase: (userParams: {
|
|
60
|
+
/** The user id extracted from the request cookie. */
|
|
61
|
+
userId: UserId;
|
|
62
|
+
/** Indicates that we're loading the user from a sign up cookie. */
|
|
63
|
+
isSignUpCookie: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* If this is set, we're attempting to load a database user for the purpose of assuming
|
|
66
|
+
* their user identity. Otherwise, this is `undefined`.
|
|
67
|
+
*/
|
|
68
|
+
assumedUserParams: AssumedUserParams | undefined;
|
|
69
|
+
}) => MaybePromise<DatabaseUser | undefined | null>;
|
|
70
|
+
/**
|
|
71
|
+
* Get JWT keys produced by {@link generateNewJwtKeys}. Make sure that each time this is
|
|
72
|
+
* called, the same JWT keys are returned (do not call {@link generateNewJwtKeys} each time
|
|
73
|
+
* this is called). Any time the JWT keys change, all current sessions will terminate.
|
|
74
|
+
*/
|
|
75
|
+
getJetKeys: () => MaybePromise<Readonly<RawJwtKeys>>;
|
|
76
|
+
/**
|
|
77
|
+
* When `isDev` is set, cookies do not require HTTPS (so they can be used with
|
|
78
|
+
* http://localhost).
|
|
79
|
+
*/
|
|
80
|
+
isDev: boolean;
|
|
81
|
+
} & PartialWithUndefined<{
|
|
82
|
+
/**
|
|
83
|
+
* Set this to allow specific users (determined by `canAssumeUser`) to assume the identity
|
|
84
|
+
* of other users. This should only be used for admins so that they can troubleshoot user
|
|
85
|
+
* issues.
|
|
86
|
+
*
|
|
87
|
+
* @see {@link AuthHeaderName}
|
|
88
|
+
*/
|
|
89
|
+
assumeUser: {
|
|
90
|
+
/**
|
|
91
|
+
* Handles assumed user header value.
|
|
92
|
+
*
|
|
93
|
+
* @see {@link AuthHeaderName}
|
|
94
|
+
*/
|
|
95
|
+
handleAssumedUserData: (
|
|
96
|
+
/**
|
|
97
|
+
* The assumed user header value.
|
|
98
|
+
*
|
|
99
|
+
* @see {@link AuthHeaderName}
|
|
100
|
+
*/
|
|
101
|
+
data: string,
|
|
102
|
+
) => MaybePromise<
|
|
103
|
+
| {
|
|
104
|
+
assumedUserParams: AssumedUserParams;
|
|
105
|
+
userId: UserId;
|
|
106
|
+
}
|
|
107
|
+
| undefined
|
|
108
|
+
>;
|
|
109
|
+
/**
|
|
110
|
+
* Return `true` to allow the current user (by the given id) to assume identities of
|
|
111
|
+
* other users. Return `false` to block it. It is recommended to only return `true` for
|
|
112
|
+
* admin users.
|
|
113
|
+
*
|
|
114
|
+
* @see {@link AuthHeaderName}
|
|
115
|
+
*/
|
|
116
|
+
canAssumeUser: (params: {userId: UserId}) => MaybePromise<boolean>;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* This determines how long a cookie will be valid until it needs to be refreshed.
|
|
120
|
+
*
|
|
121
|
+
* @default {minutes: 20}
|
|
122
|
+
*/
|
|
123
|
+
userSessionIdleTimeout: Readonly<AnyDuration>;
|
|
124
|
+
/**
|
|
125
|
+
* How long before a user's session times out when we should start trying to refresh their
|
|
126
|
+
* session.
|
|
127
|
+
*
|
|
128
|
+
* @default {minutes: 5}
|
|
129
|
+
*/
|
|
130
|
+
sessionRefreshThreshold: Readonly<AnyDuration>;
|
|
131
|
+
overrides: PartialWithUndefined<{
|
|
132
|
+
csrfHeaderName: CsrfHeaderName;
|
|
133
|
+
assumedUserHeaderName: string;
|
|
134
|
+
}>;
|
|
135
|
+
}>
|
|
136
|
+
>;
|
|
137
|
+
|
|
138
|
+
const defaultSessionIdleTimeout: Readonly<AnyDuration> = {
|
|
139
|
+
minutes: 20,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* An auth client for creating and validating JWTs embedded in cookies. This should only be used in
|
|
144
|
+
* a backend environment as it accesses native Node packages.
|
|
145
|
+
*
|
|
146
|
+
* @category Auth : Host
|
|
147
|
+
* @category Client
|
|
148
|
+
*/
|
|
149
|
+
export class BackendAuthClient<
|
|
150
|
+
DatabaseUser extends AnyObject,
|
|
151
|
+
UserId extends string | number,
|
|
152
|
+
CsrfHeaderName extends string = AuthHeaderName.CsrfToken,
|
|
153
|
+
AssumedUserParams extends AnyObject = EmptyObject,
|
|
154
|
+
> {
|
|
155
|
+
protected cachedParsedJwtKeys: Record<string, Readonly<JwtKeys>> = {};
|
|
156
|
+
|
|
157
|
+
constructor(
|
|
158
|
+
protected readonly config: BackendAuthClientConfig<
|
|
159
|
+
DatabaseUser,
|
|
160
|
+
UserId,
|
|
161
|
+
CsrfHeaderName,
|
|
162
|
+
AssumedUserParams
|
|
163
|
+
>,
|
|
164
|
+
) {}
|
|
165
|
+
|
|
166
|
+
/** Get all the parameters used for cookie generation. */
|
|
167
|
+
protected async getCookieParams({
|
|
168
|
+
isSignUpCookie,
|
|
169
|
+
}: {
|
|
170
|
+
/**
|
|
171
|
+
* Set this to `true` when we are setting the initial cookie right after a user signs up.
|
|
172
|
+
* This allows them to auto-authorize when they verify their email address.
|
|
173
|
+
*
|
|
174
|
+
* This should only be set to `true` when a new user is signing up.
|
|
175
|
+
*/
|
|
176
|
+
isSignUpCookie?: boolean | undefined;
|
|
177
|
+
}): Promise<Readonly<CookieParams>> {
|
|
178
|
+
return {
|
|
179
|
+
cookieDuration: this.config.userSessionIdleTimeout || defaultSessionIdleTimeout,
|
|
180
|
+
hostOrigin: this.config.serviceOrigin,
|
|
181
|
+
jwtParams: await this.getJwtParams(),
|
|
182
|
+
isDev: this.config.isDev,
|
|
183
|
+
cookieName: isSignUpCookie ? AuthCookieName.SignUp : AuthCookieName.Auth,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Calls the provided `getUserFromDatabase` config. */
|
|
188
|
+
protected async getDatabaseUser({
|
|
189
|
+
isSignUpCookie,
|
|
190
|
+
userId,
|
|
191
|
+
assumedUserParams,
|
|
192
|
+
}: {
|
|
193
|
+
userId: UserId | undefined;
|
|
194
|
+
assumedUserParams: AssumedUserParams | undefined;
|
|
195
|
+
isSignUpCookie: boolean;
|
|
196
|
+
}): Promise<undefined | DatabaseUser> {
|
|
197
|
+
if (!userId) {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const authenticatedUser = await this.config.getUserFromDatabase({
|
|
202
|
+
assumedUserParams,
|
|
203
|
+
userId,
|
|
204
|
+
isSignUpCookie,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
if (!authenticatedUser) {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return authenticatedUser;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Creates a `'cookie-set'` header to refresh the user's session cookie. */
|
|
215
|
+
protected async createCookieRefreshHeaders({
|
|
216
|
+
userIdResult,
|
|
217
|
+
}: {
|
|
218
|
+
userIdResult: Readonly<UserIdResult<UserId>>;
|
|
219
|
+
}): Promise<OutgoingHttpHeaders | undefined> {
|
|
220
|
+
const now = getNowInUtcTimezone();
|
|
221
|
+
|
|
222
|
+
/** Double check that the JWT hasn't already expired. */
|
|
223
|
+
const isExpiredAlready = isDateAfter({
|
|
224
|
+
fullDate: now,
|
|
225
|
+
relativeTo: userIdResult.jwtExpiration,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
if (isExpiredAlready) {
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* This check performs the following: the current time + the refresh threshold > JWT
|
|
234
|
+
* expiration.
|
|
235
|
+
*
|
|
236
|
+
* Visually, this check looks like this:
|
|
237
|
+
*
|
|
238
|
+
* X C=======Y=======R Z
|
|
239
|
+
*
|
|
240
|
+
* - C = current time
|
|
241
|
+
* - R = C + refresh threshold
|
|
242
|
+
* - `=` = the time frame in which {@link isRefreshReady} = true.
|
|
243
|
+
* - X = JWT expiration that has already expired (rejected by {@link isExpiredAlready}.
|
|
244
|
+
* - Y = JWT expiration within the refresh threshold: {@link isRefreshReady} = true.
|
|
245
|
+
* - Z = JWT expiration outside the refresh threshold: {@link isRefreshReady} = false.
|
|
246
|
+
*/
|
|
247
|
+
const isRefreshReady = isDateAfter({
|
|
248
|
+
fullDate: calculateRelativeDate(
|
|
249
|
+
now,
|
|
250
|
+
this.config.sessionRefreshThreshold || {
|
|
251
|
+
minutes: 5,
|
|
252
|
+
},
|
|
253
|
+
),
|
|
254
|
+
relativeTo: userIdResult.jwtExpiration,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
if (isRefreshReady) {
|
|
258
|
+
return this.createLoginHeaders({
|
|
259
|
+
requestHeaders: {},
|
|
260
|
+
userId: userIdResult.userId,
|
|
261
|
+
isSignUpCookie: userIdResult.cookieName === AuthCookieName.SignUp,
|
|
262
|
+
});
|
|
263
|
+
} else {
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Reads the user's assumed user headers and, if configured, gets the assumed user. */
|
|
269
|
+
protected async getAssumedUser({
|
|
270
|
+
headers,
|
|
271
|
+
originalUserId,
|
|
272
|
+
}: {
|
|
273
|
+
originalUserId: UserId | undefined;
|
|
274
|
+
headers: IncomingHttpHeaders;
|
|
275
|
+
}): Promise<DatabaseUser | undefined> {
|
|
276
|
+
if (
|
|
277
|
+
!originalUserId ||
|
|
278
|
+
!this.config.assumeUser ||
|
|
279
|
+
!(await this.config.assumeUser.canAssumeUser({
|
|
280
|
+
userId: originalUserId,
|
|
281
|
+
}))
|
|
282
|
+
) {
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const assumedUserHeader: string | undefined = ensureArray(
|
|
287
|
+
headers[this.config.overrides?.assumedUserHeaderName || AuthHeaderName.AssumedUser],
|
|
288
|
+
)[0];
|
|
289
|
+
|
|
290
|
+
if (!assumedUserHeader) {
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const parsedAssumedUserData =
|
|
295
|
+
await this.config.assumeUser.handleAssumedUserData(assumedUserHeader);
|
|
296
|
+
|
|
297
|
+
if (!parsedAssumedUserData || !parsedAssumedUserData.userId) {
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const assumedUser = await this.getDatabaseUser({
|
|
302
|
+
isSignUpCookie: false,
|
|
303
|
+
userId: parsedAssumedUserData.userId,
|
|
304
|
+
assumedUserParams: parsedAssumedUserData.assumedUserParams,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
return assumedUser;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Securely extract a user from their request headers. */
|
|
311
|
+
public async getSecureUser({
|
|
312
|
+
requestHeaders,
|
|
313
|
+
isSignUpCookie,
|
|
314
|
+
}: {
|
|
315
|
+
requestHeaders: IncomingHttpHeaders;
|
|
316
|
+
isSignUpCookie?: boolean | undefined;
|
|
317
|
+
}): Promise<GetUserResult<DatabaseUser> | undefined> {
|
|
318
|
+
const userIdResult = await extractUserIdFromRequestHeaders<UserId>(
|
|
319
|
+
requestHeaders,
|
|
320
|
+
await this.getJwtParams(),
|
|
321
|
+
isSignUpCookie ? AuthCookieName.SignUp : AuthCookieName.Auth,
|
|
322
|
+
this.config.overrides,
|
|
323
|
+
);
|
|
324
|
+
if (!userIdResult) {
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const user = await this.getDatabaseUser({
|
|
329
|
+
userId: userIdResult.userId,
|
|
330
|
+
assumedUserParams: undefined,
|
|
331
|
+
isSignUpCookie: !!isSignUpCookie,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
if (!user) {
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const assumedUser = await this.getAssumedUser({
|
|
339
|
+
headers: requestHeaders,
|
|
340
|
+
originalUserId: userIdResult.userId,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const cookieRefreshHeaders =
|
|
344
|
+
(await this.createCookieRefreshHeaders({
|
|
345
|
+
userIdResult,
|
|
346
|
+
})) || {};
|
|
347
|
+
|
|
348
|
+
if (assumedUser) {
|
|
349
|
+
return {
|
|
350
|
+
user: assumedUser,
|
|
351
|
+
isAssumed: true,
|
|
352
|
+
responseHeaders: cookieRefreshHeaders,
|
|
353
|
+
};
|
|
354
|
+
} else {
|
|
355
|
+
return {
|
|
356
|
+
user,
|
|
357
|
+
isAssumed: false,
|
|
358
|
+
responseHeaders: cookieRefreshHeaders,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Get all the JWT params used when creating the auth cookie, in case you need them for
|
|
365
|
+
* something else too.
|
|
366
|
+
*/
|
|
367
|
+
public async getJwtParams(): Promise<Readonly<CreateJwtParams>> {
|
|
368
|
+
const rawJwtKeys = await this.config.getJetKeys();
|
|
369
|
+
|
|
370
|
+
const cacheKey = JSON.stringify(rawJwtKeys);
|
|
371
|
+
|
|
372
|
+
const cachedParsedKeys = this.cachedParsedJwtKeys[cacheKey];
|
|
373
|
+
const parsedKeys = cachedParsedKeys ?? (await parseJwtKeys(rawJwtKeys));
|
|
374
|
+
|
|
375
|
+
if (!cachedParsedKeys) {
|
|
376
|
+
this.cachedParsedJwtKeys = {[cacheKey]: parsedKeys};
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
jwtKeys: parsedKeys,
|
|
380
|
+
audience: 'server-context',
|
|
381
|
+
issuer: 'server-auth',
|
|
382
|
+
jwtDuration: this.config.userSessionIdleTimeout || defaultSessionIdleTimeout,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Use these headers to log out the user. */
|
|
387
|
+
public async createLogoutHeaders(): Promise<
|
|
388
|
+
{
|
|
389
|
+
'set-cookie': string;
|
|
390
|
+
} & Record<CsrfHeaderName, string>
|
|
391
|
+
> {
|
|
392
|
+
const signUpCookieHeaders = generateLogoutHeaders(
|
|
393
|
+
await this.getCookieParams({
|
|
394
|
+
isSignUpCookie: true,
|
|
395
|
+
}),
|
|
396
|
+
this.config.overrides,
|
|
397
|
+
);
|
|
398
|
+
const authCookieHeaders = generateLogoutHeaders(
|
|
399
|
+
await this.getCookieParams({
|
|
400
|
+
isSignUpCookie: false,
|
|
401
|
+
}),
|
|
402
|
+
this.config.overrides,
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
...authCookieHeaders,
|
|
407
|
+
'set-cookie': mergeHeaderValues(
|
|
408
|
+
signUpCookieHeaders['set-cookie'],
|
|
409
|
+
authCookieHeaders['set-cookie'],
|
|
410
|
+
),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Use these headers to log a user in. */
|
|
415
|
+
public async createLoginHeaders({
|
|
416
|
+
userId,
|
|
417
|
+
requestHeaders,
|
|
418
|
+
isSignUpCookie,
|
|
419
|
+
}: {
|
|
420
|
+
userId: UserId;
|
|
421
|
+
requestHeaders: IncomingHttpHeaders;
|
|
422
|
+
isSignUpCookie: boolean;
|
|
423
|
+
}): Promise<
|
|
424
|
+
Pick<RequiredAndNotNull<OutgoingHttpHeaders>, 'set-cookie'> & Record<CsrfHeaderName, string>
|
|
425
|
+
> {
|
|
426
|
+
const oppositeCookieName = isSignUpCookie ? AuthCookieName.Auth : AuthCookieName.SignUp;
|
|
427
|
+
const hasExistingOppositeCookie = requestHeaders.cookie?.includes(`${oppositeCookieName}=`);
|
|
428
|
+
|
|
429
|
+
const discardOppositeCookieHeaders = hasExistingOppositeCookie
|
|
430
|
+
? generateLogoutHeaders(
|
|
431
|
+
await this.getCookieParams({
|
|
432
|
+
isSignUpCookie: !isSignUpCookie,
|
|
433
|
+
}),
|
|
434
|
+
this.config.overrides,
|
|
435
|
+
)
|
|
436
|
+
: undefined;
|
|
437
|
+
|
|
438
|
+
const newCookieHeaders = await generateSuccessfulLoginHeaders(
|
|
439
|
+
userId,
|
|
440
|
+
await this.getCookieParams({
|
|
441
|
+
isSignUpCookie,
|
|
442
|
+
}),
|
|
443
|
+
this.config.overrides,
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
...newCookieHeaders,
|
|
448
|
+
'set-cookie': mergeHeaderValues(
|
|
449
|
+
newCookieHeaders['set-cookie'],
|
|
450
|
+
discardOppositeCookieHeaders?.['set-cookie'],
|
|
451
|
+
),
|
|
452
|
+
...(isSignUpCookie
|
|
453
|
+
? {
|
|
454
|
+
[AuthHeaderName.IsSignUpAuth]: 'true',
|
|
455
|
+
}
|
|
456
|
+
: {}),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* @deprecated This only half authenticates the user. It should only be used in circumstances
|
|
462
|
+
* where JavaScript cannot be used to attach the CSRF token header to the request (like when
|
|
463
|
+
* opening a PDF file). Use `.getSecureUser()` instead, whenever possible.
|
|
464
|
+
*/
|
|
465
|
+
public async getInsecureUser({
|
|
466
|
+
headers,
|
|
467
|
+
}: {
|
|
468
|
+
headers: IncomingHttpHeaders;
|
|
469
|
+
}): Promise<GetUserResult<DatabaseUser> | undefined> {
|
|
470
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
471
|
+
const userIdResult = await insecureExtractUserIdFromCookieAlone<UserId>(
|
|
472
|
+
headers,
|
|
473
|
+
await this.getJwtParams(),
|
|
474
|
+
AuthCookieName.Auth,
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
if (!userIdResult) {
|
|
478
|
+
return undefined;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const user = await this.getDatabaseUser({
|
|
482
|
+
isSignUpCookie: false,
|
|
483
|
+
userId: userIdResult.userId,
|
|
484
|
+
assumedUserParams: undefined,
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
if (!user) {
|
|
488
|
+
return undefined;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
user,
|
|
493
|
+
isAssumed: false,
|
|
494
|
+
responseHeaders:
|
|
495
|
+
(await this.createCookieRefreshHeaders({
|
|
496
|
+
userIdResult,
|
|
497
|
+
})) || {},
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HttpStatus,
|
|
3
|
+
type JsonCompatibleObject,
|
|
4
|
+
type MaybePromise,
|
|
5
|
+
type PartialWithUndefined,
|
|
6
|
+
type SelectFrom,
|
|
7
|
+
} from '@augment-vir/common';
|
|
8
|
+
import {type EmptyObject} from 'type-fest';
|
|
9
|
+
import {
|
|
10
|
+
CsrfTokenFailureReason,
|
|
11
|
+
extractCsrfTokenHeader,
|
|
12
|
+
getCurrentCsrfToken,
|
|
13
|
+
storeCsrfToken,
|
|
14
|
+
wipeCurrentCsrfToken,
|
|
15
|
+
} from '../csrf-token.js';
|
|
16
|
+
import {AuthHeaderName} from '../headers.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Config for {@link FrontendAuthClient}.
|
|
20
|
+
*
|
|
21
|
+
* @category Internal
|
|
22
|
+
*/
|
|
23
|
+
export type FrontendAuthClientConfig = PartialWithUndefined<{
|
|
24
|
+
/**
|
|
25
|
+
* Determine if the current user can assume the identity of another user. If this is not
|
|
26
|
+
* defined, all users will be blocked from assuming other user identities.
|
|
27
|
+
*/
|
|
28
|
+
canAssumeUser: () => MaybePromise<boolean>;
|
|
29
|
+
/** Called whenever the current user becomes unauthorized and their CSRF token is wiped. */
|
|
30
|
+
authClearedCallback: () => MaybePromise<void>;
|
|
31
|
+
overrides: PartialWithUndefined<{
|
|
32
|
+
localStorage: Pick<Storage, 'setItem' | 'removeItem' | 'getItem'>;
|
|
33
|
+
csrfHeaderName: string;
|
|
34
|
+
assumedUserHeaderName: string;
|
|
35
|
+
}>;
|
|
36
|
+
}>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* An auth client for sending and validating client requests to a backend. This should only be used
|
|
40
|
+
* in a frontend environment as it accesses native browser APIs.
|
|
41
|
+
*
|
|
42
|
+
* @category Auth : Client
|
|
43
|
+
* @category Client
|
|
44
|
+
*/
|
|
45
|
+
export class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject = EmptyObject> {
|
|
46
|
+
constructor(protected readonly config: FrontendAuthClientConfig = {}) {}
|
|
47
|
+
|
|
48
|
+
/** Wraps {@link getCurrentCsrfToken} to automatically handle wiping an invalid CSRF token. */
|
|
49
|
+
public async getCurrentCsrfToken(): Promise<string | undefined> {
|
|
50
|
+
const csrfTokenResult = getCurrentCsrfToken(this.config.overrides);
|
|
51
|
+
|
|
52
|
+
if (
|
|
53
|
+
csrfTokenResult.failure &&
|
|
54
|
+
csrfTokenResult.failure !== CsrfTokenFailureReason.DoesNotExist
|
|
55
|
+
) {
|
|
56
|
+
await this.logout();
|
|
57
|
+
return undefined;
|
|
58
|
+
} else {
|
|
59
|
+
return csrfTokenResult.csrfToken?.token;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @returns Whether the user assuming succeeded or not. */
|
|
64
|
+
public async assumeUser(assumedUserParams: Readonly<AssumedUserParams>): Promise<boolean> {
|
|
65
|
+
if (!(await this.config.canAssumeUser?.())) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
(this.config.overrides?.localStorage || globalThis.localStorage).setItem(
|
|
70
|
+
this.config.overrides?.assumedUserHeaderName || AuthHeaderName.AssumedUser,
|
|
71
|
+
JSON.stringify(assumedUserParams),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
public getAssumedUser(): AssumedUserParams | undefined {
|
|
78
|
+
const rawValue = (this.config.overrides?.localStorage || globalThis.localStorage).getItem(
|
|
79
|
+
this.config.overrides?.assumedUserHeaderName || AuthHeaderName.AssumedUser,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (!rawValue) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(rawValue);
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Creates a `RequestInit` object for the `fetch` API. If you have other request init options,
|
|
94
|
+
* use [`mergeDeep` from
|
|
95
|
+
* `@augment-vir/common`](https://electrovir.github.io/augment-vir/functions/mergeDeep.html) to
|
|
96
|
+
* combine them with these.
|
|
97
|
+
*/
|
|
98
|
+
public async createAuthenticatedRequestInit(): Promise<RequestInit> {
|
|
99
|
+
const csrfToken = await this.getCurrentCsrfToken();
|
|
100
|
+
|
|
101
|
+
const assumedUser = this.getAssumedUser();
|
|
102
|
+
const headers: HeadersInit = {
|
|
103
|
+
...(csrfToken
|
|
104
|
+
? {
|
|
105
|
+
[AuthHeaderName.CsrfToken]: csrfToken,
|
|
106
|
+
}
|
|
107
|
+
: {}),
|
|
108
|
+
...(assumedUser
|
|
109
|
+
? {
|
|
110
|
+
[this.config.overrides?.assumedUserHeaderName || AuthHeaderName.AssumedUser]:
|
|
111
|
+
JSON.stringify(assumedUser),
|
|
112
|
+
}
|
|
113
|
+
: {}),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
headers,
|
|
118
|
+
credentials: 'include',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Wipes the current user auth. */
|
|
123
|
+
public async logout() {
|
|
124
|
+
await this.config.authClearedCallback?.();
|
|
125
|
+
wipeCurrentCsrfToken(this.config.overrides);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Use to handle a login response. Automatically stores the CSRF token.
|
|
130
|
+
*
|
|
131
|
+
* @throws Error if the login response failed.
|
|
132
|
+
* @throws Error if the login response has an invalid CSRF token.
|
|
133
|
+
*/
|
|
134
|
+
public async handleLoginResponse(
|
|
135
|
+
response: Readonly<
|
|
136
|
+
SelectFrom<
|
|
137
|
+
Response,
|
|
138
|
+
{
|
|
139
|
+
headers: true;
|
|
140
|
+
ok: true;
|
|
141
|
+
}
|
|
142
|
+
>
|
|
143
|
+
>,
|
|
144
|
+
): Promise<void> {
|
|
145
|
+
if (!response.ok) {
|
|
146
|
+
await this.logout();
|
|
147
|
+
throw new Error('Login response failed.');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const {csrfToken} = extractCsrfTokenHeader(response, this.config.overrides);
|
|
151
|
+
|
|
152
|
+
if (!csrfToken) {
|
|
153
|
+
await this.logout();
|
|
154
|
+
throw new Error('Did not receive any CSRF token.');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
storeCsrfToken(csrfToken, this.config.overrides);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Use to verify _all_ responses received from the backend. Immediately logs the user out once
|
|
162
|
+
* an unauthorized response is detected.
|
|
163
|
+
*/
|
|
164
|
+
public async verifyResponseAuth(
|
|
165
|
+
response: Readonly<
|
|
166
|
+
SelectFrom<
|
|
167
|
+
Response,
|
|
168
|
+
{
|
|
169
|
+
status: true;
|
|
170
|
+
headers: true;
|
|
171
|
+
}
|
|
172
|
+
>
|
|
173
|
+
>,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
if (
|
|
176
|
+
response.status === HttpStatus.Unauthorized &&
|
|
177
|
+
!response.headers.get(AuthHeaderName.IsSignUpAuth)
|
|
178
|
+
) {
|
|
179
|
+
await this.logout();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|