@idp.global/sdk 18.0.1 → 18.1.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.
Files changed (50) hide show
  1. package/changelog.md +17 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts_relyingparty/classes.actorscope.d.ts +23 -0
  4. package/dist_ts_relyingparty/classes.actorscope.js +22 -0
  5. package/dist_ts_relyingparty/classes.authorityobserver.d.ts +81 -0
  6. package/dist_ts_relyingparty/classes.authorityobserver.js +185 -0
  7. package/dist_ts_relyingparty/classes.errors.d.ts +33 -0
  8. package/dist_ts_relyingparty/classes.errors.js +53 -0
  9. package/dist_ts_relyingparty/classes.idpoidcprovider.d.ts +142 -0
  10. package/dist_ts_relyingparty/classes.idpoidcprovider.js +255 -0
  11. package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +84 -0
  12. package/dist_ts_relyingparty/classes.relyingpartyroutes.js +232 -0
  13. package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +210 -0
  14. package/dist_ts_relyingparty/classes.relyingpartysessions.js +786 -0
  15. package/dist_ts_relyingparty/classes.rpmodels.d.ts +79 -0
  16. package/dist_ts_relyingparty/classes.rpmodels.js +283 -0
  17. package/dist_ts_relyingparty/classes.secretbox.d.ts +14 -0
  18. package/dist_ts_relyingparty/classes.secretbox.js +45 -0
  19. package/dist_ts_relyingparty/classes.socketbinding.d.ts +75 -0
  20. package/dist_ts_relyingparty/classes.socketbinding.js +94 -0
  21. package/dist_ts_relyingparty/helpers.cookies.d.ts +11 -0
  22. package/dist_ts_relyingparty/helpers.cookies.js +62 -0
  23. package/dist_ts_relyingparty/helpers.crypto.d.ts +12 -0
  24. package/dist_ts_relyingparty/helpers.crypto.js +40 -0
  25. package/dist_ts_relyingparty/helpers.returnpath.d.ts +9 -0
  26. package/dist_ts_relyingparty/helpers.returnpath.js +39 -0
  27. package/dist_ts_relyingparty/index.d.ts +13 -0
  28. package/dist_ts_relyingparty/index.js +14 -0
  29. package/dist_ts_relyingparty/interfaces.d.ts +113 -0
  30. package/dist_ts_relyingparty/interfaces.js +2 -0
  31. package/dist_ts_relyingparty/plugins.d.ts +10 -0
  32. package/dist_ts_relyingparty/plugins.js +11 -0
  33. package/package.json +13 -10
  34. package/readme.md +158 -4
  35. package/ts/00_commitinfo_data.ts +1 -1
  36. package/ts_relyingparty/classes.actorscope.ts +39 -0
  37. package/ts_relyingparty/classes.authorityobserver.ts +253 -0
  38. package/ts_relyingparty/classes.errors.ts +69 -0
  39. package/ts_relyingparty/classes.idpoidcprovider.ts +386 -0
  40. package/ts_relyingparty/classes.relyingpartyroutes.ts +286 -0
  41. package/ts_relyingparty/classes.relyingpartysessions.ts +970 -0
  42. package/ts_relyingparty/classes.rpmodels.ts +146 -0
  43. package/ts_relyingparty/classes.secretbox.ts +49 -0
  44. package/ts_relyingparty/classes.socketbinding.ts +142 -0
  45. package/ts_relyingparty/helpers.cookies.ts +79 -0
  46. package/ts_relyingparty/helpers.crypto.ts +49 -0
  47. package/ts_relyingparty/helpers.returnpath.ts +44 -0
  48. package/ts_relyingparty/index.ts +13 -0
  49. package/ts_relyingparty/interfaces.ts +133 -0
  50. package/ts_relyingparty/plugins.ts +17 -0
@@ -0,0 +1,970 @@
1
+ import { SecretBox } from './classes.secretbox.js';
2
+ import {
3
+ AuthorityTokenError,
4
+ LoginCallbackError,
5
+ RelyingPartyError,
6
+ RelyingPartyRefusal,
7
+ } from './classes.errors.js';
8
+ import {
9
+ OidcCompletionError,
10
+ OidcRefreshRefusedError,
11
+ type IOidcCompletion,
12
+ type IOidcProvider,
13
+ type IOidcRefreshedTokens,
14
+ type TOidcTokenHint,
15
+ } from './classes.idpoidcprovider.js';
16
+ import type { IRelyingPartyModels, RelyingPartyLoginAttemptDoc, RelyingPartySessionDoc } from './classes.rpmodels.js';
17
+ import { parseCookies, serializeClearedCookie, serializeCookie } from './helpers.cookies.js';
18
+ import { deriveCsrfToken, randomToken, sha256, timingSafeEqualString } from './helpers.crypto.js';
19
+ import { sanitizeReturnPath } from './helpers.returnpath.js';
20
+ import type {
21
+ IAuthorityTokenSource,
22
+ IProviderToken,
23
+ IRelyingPartyEvent,
24
+ IRelyingPartyHooks,
25
+ IRelyingPartyLoginStart,
26
+ IRelyingPartyPrincipals,
27
+ IRelyingPartySession,
28
+ IRelyingPartySessionOpening,
29
+ IResolvedRelyingPartySession,
30
+ TLoginCallbackStage,
31
+ TRelyingPartySessionEndReason,
32
+ } from './interfaces.js';
33
+
34
+ export const RELYING_PARTY_DEFAULT_SESSION_MAX_AGE_SECONDS = 12 * 60 * 60;
35
+ export const RELYING_PARTY_DEFAULT_LOGIN_PATH_PREFIX = '/auth/';
36
+ /** The label the CSRF HMAC starts with unless the application names its own. */
37
+ export const RELYING_PARTY_DEFAULT_CSRF_LABEL = 'idp.global/sdk relyingparty csrf' + String.fromCharCode(0);
38
+
39
+ const MAX_COOKIE_TOKEN_LENGTH = 256;
40
+ const SESSION_TOUCH_INTERVAL_MS = 60_000;
41
+ /** A sign-in transaction has to survive an interactive login at the provider, not a working day. */
42
+ export const LOGIN_ATTEMPT_MAX_AGE_SECONDS = 10 * 60;
43
+ /** Attempts are swept once they are this much older than their expiry. */
44
+ const LOGIN_ATTEMPT_SWEEP_GRACE_MS = 60 * 60 * 1_000;
45
+ /** How many of them one sign-in removes; the next sign-in takes the following page. */
46
+ const LOGIN_ATTEMPT_SWEEP_BATCH = 100;
47
+ /**
48
+ * How long ending a session waits for the provider to take its tokens back.
49
+ * The local session is already revoked when that call runs.
50
+ */
51
+ export const TOKEN_REVOCATION_BUDGET_MS = 3_000;
52
+ /** How long before its expiry the provider access token is rotated. */
53
+ export const ACCESS_TOKEN_REFRESH_SKEW_MS = 60_000;
54
+ /**
55
+ * What the refresh lock adds on top of the provider request it has to cover:
56
+ * the claim and the store around it, and clock disagreement between replicas.
57
+ */
58
+ export const REFRESH_LOCK_MARGIN_MS = 20_000;
59
+
60
+ export interface IRelyingPartySessionsOptions {
61
+ models: IRelyingPartyModels;
62
+ principals: IRelyingPartyPrincipals;
63
+ /** absent: no provider login; sessions are opened by the application only */
64
+ provider?: IOidcProvider;
65
+ /** the application's public origin, e.g. `https://app.example`; cookies are `Secure` exactly on https */
66
+ publicOrigin: string;
67
+ /** 32 bytes; seals the PKCE verifier and the provider tokens at rest */
68
+ sessionEncryptionKey: Buffer;
69
+ /** 32 bytes; the CSRF HMAC key */
70
+ csrfKey: Buffer;
71
+ /** the label the CSRF HMAC starts with; an application with live sessions keeps its own */
72
+ csrfLabel?: string;
73
+ sessionMaxAgeSeconds?: number;
74
+ /**
75
+ * defaults: `__Host-idp_rp_session` / `__Host-idp_rp_intent` on https,
76
+ * without the prefix otherwise. A `__Host-` or `__Secure-` name needs an
77
+ * https origin and is refused on http.
78
+ */
79
+ cookieNames?: {
80
+ session?: string;
81
+ intent?: string;
82
+ };
83
+ /** the prefix of the login routes; a login never returns into it */
84
+ loginPathPrefix?: string;
85
+ hooks?: IRelyingPartyHooks;
86
+ /** the clock every comparison reads; tests step it instead of sleeping */
87
+ now?: () => number;
88
+ }
89
+
90
+ type TSealedTokens = Pick<RelyingPartySessionDoc, 'sealedAccessToken' | 'sealedRefreshToken'>;
91
+
92
+ const REVOKED_TOKEN_FIELDS = { sealedAccessToken: '', sealedRefreshToken: '', refreshLockUntil: 0 };
93
+
94
+ const errorMessage = (errorArg: unknown): string => (errorArg instanceof Error ? errorArg.message : String(errorArg));
95
+
96
+ /** Browsers store a cookie with one of these prefixes only when it is set `Secure`; they match it case-insensitively. */
97
+ const SECURE_COOKIE_NAME_PREFIX = /^__(host|secure)-/i;
98
+
99
+ const cookieNameFor = (
100
+ configuredArg: string | undefined,
101
+ defaultArg: string,
102
+ secureArg: boolean,
103
+ labelArg: 'session' | 'intent',
104
+ ): string => {
105
+ if (configuredArg === undefined) {
106
+ return defaultArg;
107
+ }
108
+ if (!secureArg && SECURE_COOKIE_NAME_PREFIX.test(configuredArg)) {
109
+ throw new Error(
110
+ `cookieNames.${labelArg} "${configuredArg}" needs an https publicOrigin: `
111
+ + 'browsers drop a __Host- or __Secure- cookie that is not set Secure',
112
+ );
113
+ }
114
+ return configuredArg;
115
+ };
116
+
117
+ /**
118
+ * The relying-party session layer: provider logins through the authorization
119
+ * code flow with PKCE, HttpOnly cookie sessions with a session-bound CSRF
120
+ * token, provider tokens sealed at rest and rotated under a cross-replica
121
+ * lock, and session ends that hand the tokens back.
122
+ *
123
+ * Nothing here runs on a timer: expired login attempts are swept when the next
124
+ * login starts, and the only timer — the revocation budget — is unreferenced
125
+ * and cleared on every path.
126
+ */
127
+ export class RelyingPartySessions implements IAuthorityTokenSource {
128
+ public readonly publicOrigin: string;
129
+ public readonly secureCookies: boolean;
130
+ public readonly sessionCookieName: string;
131
+ public readonly intentCookieName: string;
132
+ public readonly loginPathPrefix: string;
133
+ public readonly sessionMaxAgeSeconds: number;
134
+ readonly #models: IRelyingPartyModels;
135
+ readonly #principals: IRelyingPartyPrincipals;
136
+ readonly #provider?: IOidcProvider;
137
+ readonly #secretBox: SecretBox;
138
+ readonly #csrfKey: Buffer;
139
+ readonly #csrfLabel: string;
140
+ readonly #hooks: IRelyingPartyHooks;
141
+ readonly #now: () => number;
142
+ /** one rotation per session in this process, however many requests need it at once */
143
+ readonly #refreshesInFlight = new Map<string, Promise<IProviderToken>>();
144
+
145
+ constructor(optionsArg: IRelyingPartySessionsOptions) {
146
+ const origin = new URL(optionsArg.publicOrigin);
147
+ if (origin.origin !== optionsArg.publicOrigin) {
148
+ throw new Error('publicOrigin must be a bare origin such as https://app.example');
149
+ }
150
+ if (!Buffer.isBuffer(optionsArg.csrfKey) || optionsArg.csrfKey.length !== 32) {
151
+ throw new Error('csrfKey must be 32 bytes');
152
+ }
153
+ const maxAge = optionsArg.sessionMaxAgeSeconds ?? RELYING_PARTY_DEFAULT_SESSION_MAX_AGE_SECONDS;
154
+ if (!Number.isSafeInteger(maxAge) || maxAge < 1) {
155
+ throw new Error('sessionMaxAgeSeconds must be a positive integer');
156
+ }
157
+ this.publicOrigin = origin.origin;
158
+ this.secureCookies = origin.protocol === 'https:';
159
+ const prefix = this.secureCookies ? '__Host-' : '';
160
+ this.sessionCookieName = cookieNameFor(
161
+ optionsArg.cookieNames?.session,
162
+ prefix + 'idp_rp_session',
163
+ this.secureCookies,
164
+ 'session',
165
+ );
166
+ this.intentCookieName = cookieNameFor(
167
+ optionsArg.cookieNames?.intent,
168
+ prefix + 'idp_rp_intent',
169
+ this.secureCookies,
170
+ 'intent',
171
+ );
172
+ this.loginPathPrefix = optionsArg.loginPathPrefix ?? RELYING_PARTY_DEFAULT_LOGIN_PATH_PREFIX;
173
+ if (!this.loginPathPrefix.startsWith('/') || !this.loginPathPrefix.endsWith('/')) {
174
+ throw new Error('loginPathPrefix must start and end with a slash');
175
+ }
176
+ this.sessionMaxAgeSeconds = maxAge;
177
+ this.#models = optionsArg.models;
178
+ this.#principals = optionsArg.principals;
179
+ this.#provider = optionsArg.provider;
180
+ this.#secretBox = new SecretBox(optionsArg.sessionEncryptionKey);
181
+ this.#csrfKey = Buffer.from(optionsArg.csrfKey);
182
+ this.#csrfLabel = optionsArg.csrfLabel ?? RELYING_PARTY_DEFAULT_CSRF_LABEL;
183
+ this.#hooks = optionsArg.hooks ?? {};
184
+ this.#now = optionsArg.now ?? (() => Date.now());
185
+ }
186
+
187
+ /** Whether provider logins are offered at all. */
188
+ public get providerLoginEnabled(): boolean {
189
+ return this.#provider !== undefined;
190
+ }
191
+
192
+ /**
193
+ * How long one replica holds the right to rotate a session's tokens. The
194
+ * provider detects reuse of a superseded refresh token and kills the family,
195
+ * so the lock strictly outlives its longest possible holder: one provider
196
+ * request at its full deadline plus the margin.
197
+ */
198
+ public get refreshLockMs(): number {
199
+ return (this.#provider?.requestTimeoutSeconds ?? 0) * 1_000 + REFRESH_LOCK_MARGIN_MS;
200
+ }
201
+
202
+ // ==========================================================================
203
+ // sessions
204
+ // ==========================================================================
205
+
206
+ /**
207
+ * Opens a session the application authenticated itself (a local password
208
+ * login). It carries no provider tokens.
209
+ */
210
+ public async openSession(optionsArg: { principalId: string }): Promise<IRelyingPartySessionOpening> {
211
+ if (typeof optionsArg?.principalId !== 'string' || optionsArg.principalId.length === 0) {
212
+ throw new Error('openSession needs a principal id');
213
+ }
214
+ const token = randomToken(32);
215
+ const session = this.newSessionDoc(token, optionsArg.principalId);
216
+ await session.save();
217
+ return this.opening(session, token);
218
+ }
219
+
220
+ /** The live session behind the session cookie, or `null`. A read touches it at most once a minute. */
221
+ public async resolveCookieSession(
222
+ cookieHeaderArg: string | null | undefined,
223
+ ): Promise<IResolvedRelyingPartySession | null> {
224
+ const token = parseCookies(cookieHeaderArg).get(this.sessionCookieName);
225
+ if (!token || token.length > MAX_COOKIE_TOKEN_LENGTH) {
226
+ return null;
227
+ }
228
+ const session = await this.#models.Session.getInstance({ tokenHash: sha256(token) });
229
+ const live = await this.liveSession(session);
230
+ if (!live) {
231
+ return null;
232
+ }
233
+ return { session: this.snapshot(live), csrfToken: this.csrfTokenOf(token) };
234
+ }
235
+
236
+ /**
237
+ * Re-checks a session by its id with the same strength as a cookie request:
238
+ * live, its principal active, and touched under the liveness it was read
239
+ * with. What a pinned socket connection runs per request.
240
+ */
241
+ public async revalidateSession(sessionIdArg: string): Promise<IRelyingPartySession | null> {
242
+ if (typeof sessionIdArg !== 'string' || sessionIdArg.length === 0) {
243
+ return null;
244
+ }
245
+ const live = await this.liveSession(await this.#models.Session.getInstance({ id: sessionIdArg }));
246
+ return live ? this.snapshot(live) : null;
247
+ }
248
+
249
+ /** Whether a session is live right now, without touching it; what a sweep asks. */
250
+ public async isSessionLive(sessionIdArg: string): Promise<boolean> {
251
+ if (typeof sessionIdArg !== 'string' || sessionIdArg.length === 0) {
252
+ return false;
253
+ }
254
+ const session = await this.#models.Session.getInstance({ id: sessionIdArg });
255
+ if (!session || session.revokedAt !== undefined || session.expiresAt <= this.#now()) {
256
+ return false;
257
+ }
258
+ return this.#principals.isActive(session.userId);
259
+ }
260
+
261
+ public validateCsrf(resolvedArg: IResolvedRelyingPartySession, candidateArg: unknown): boolean {
262
+ return typeof candidateArg === 'string' && timingSafeEqualString(resolvedArg.csrfToken, candidateArg);
263
+ }
264
+
265
+ /** Ends one session; see `endSession`. */
266
+ public async logout(sessionIdArg: string): Promise<boolean> {
267
+ return this.endSession(sessionIdArg);
268
+ }
269
+
270
+ /**
271
+ * Revokes a session once, with one conditional update: a touch running
272
+ * beside it cannot write the revocation away, and a second call finds
273
+ * nothing. Its provider tokens go back within `TOKEN_REVOCATION_BUDGET_MS`,
274
+ * after the local revocation, which never depends on the provider. Answers
275
+ * whether this call ended it.
276
+ */
277
+ public async endSession(
278
+ sessionIdArg: string,
279
+ reasonArg: TRelyingPartySessionEndReason = 'logout',
280
+ ): Promise<boolean> {
281
+ const ended = await this.revokeSession(sessionIdArg, reasonArg);
282
+ if (ended) {
283
+ await this.revokeSealedTokens(ended);
284
+ }
285
+ return ended !== null;
286
+ }
287
+
288
+ /**
289
+ * Ends every live session of a principal but `keepSessionId` (a password
290
+ * change keeps the session that asked for it) and answers how many ended.
291
+ */
292
+ public async endSessionsOfPrincipal(
293
+ principalIdArg: string,
294
+ optionsArg: { keepSessionId?: string } = {},
295
+ ): Promise<number> {
296
+ let ended = 0;
297
+ const sessions = await this.#models.Session.getInstances({
298
+ userId: principalIdArg,
299
+ revokedAt: { $exists: false },
300
+ });
301
+ for (const session of sessions) {
302
+ if (session.id === optionsArg.keepSessionId) {
303
+ continue;
304
+ }
305
+ if (await this.endSession(session.id)) {
306
+ ended += 1;
307
+ }
308
+ }
309
+ return ended;
310
+ }
311
+
312
+ public clearSessionCookie(): string {
313
+ return serializeClearedCookie(this.sessionCookieName, {
314
+ httpOnly: true,
315
+ secure: this.secureCookies,
316
+ sameSite: 'Lax',
317
+ });
318
+ }
319
+
320
+ public clearIntentCookie(): string {
321
+ return serializeClearedCookie(this.intentCookieName, {
322
+ httpOnly: true,
323
+ secure: this.secureCookies,
324
+ sameSite: 'Lax',
325
+ });
326
+ }
327
+
328
+ // ==========================================================================
329
+ // provider login
330
+ // ==========================================================================
331
+
332
+ /**
333
+ * Starts a sign-in transaction: one attempt document with the PKCE verifier
334
+ * sealed next to it, and an intent cookie that binds the callback to this
335
+ * browser.
336
+ */
337
+ public async startLogin(returnPathArg: unknown): Promise<IRelyingPartyLoginStart> {
338
+ const provider = this.requireProvider();
339
+ await this.sweepExpiredAttempts();
340
+ const state = randomToken(32);
341
+ const nonce = randomToken(32);
342
+ const pkceVerifier = randomToken(32);
343
+ const bindingToken = randomToken(32);
344
+ const now = this.#now();
345
+
346
+ const attempt = new this.#models.LoginAttempt();
347
+ attempt.id = 'lga_' + randomToken(18);
348
+ attempt.stateHash = sha256(state);
349
+ attempt.bindingTokenHash = sha256(bindingToken);
350
+ attempt.sealedPkceVerifier = this.#secretBox.seal(pkceVerifier);
351
+ attempt.nonce = nonce;
352
+ attempt.returnPath = sanitizeReturnPath(returnPathArg, this.publicOrigin, this.loginPathPrefix);
353
+ attempt.createdAt = now;
354
+ attempt.expiresAt = now + LOGIN_ATTEMPT_MAX_AGE_SECONDS * 1_000;
355
+ await attempt.save();
356
+
357
+ return {
358
+ authorizationUrl: await provider.buildAuthorizationUrl({ state, nonce, pkceVerifier }),
359
+ intentCookie: serializeCookie(this.intentCookieName, bindingToken, {
360
+ httpOnly: true,
361
+ secure: this.secureCookies,
362
+ sameSite: 'Lax',
363
+ maxAgeSeconds: LOGIN_ATTEMPT_MAX_AGE_SECONDS,
364
+ }),
365
+ };
366
+ }
367
+
368
+ /**
369
+ * Consumes the attempt exactly once, exchanges the code, admits the
370
+ * principal and opens a session. A replayed callback, a foreign intent
371
+ * cookie and an expired attempt all find nothing to consume. Nothing of a
372
+ * failed login survives: its tokens are handed back before the failure
373
+ * leaves.
374
+ */
375
+ public async completeLogin(
376
+ callbackUrlArg: URL,
377
+ cookieHeaderArg: string | null | undefined,
378
+ ): Promise<IRelyingPartySessionOpening> {
379
+ const provider = this.requireProvider();
380
+ const states = callbackUrlArg.searchParams.getAll('state');
381
+ if (states.length !== 1 || !states[0]) {
382
+ throw new RelyingPartyError('invalid_login_callback');
383
+ }
384
+ const bindingToken = parseCookies(cookieHeaderArg).get(this.intentCookieName);
385
+ if (!bindingToken || bindingToken.length > MAX_COOKIE_TOKEN_LENGTH) {
386
+ throw new RelyingPartyError('missing_login_intent');
387
+ }
388
+
389
+ const now = this.#now();
390
+ const claimed = await this.#models.LoginAttempt.atomicFindOneAndUpdate(
391
+ {
392
+ stateHash: sha256(states[0]),
393
+ bindingTokenHash: sha256(bindingToken),
394
+ consumedAt: null,
395
+ expiresAt: { $gt: now },
396
+ },
397
+ { $set: { consumedAt: now } },
398
+ { returnDocument: 'after' },
399
+ );
400
+ if (claimed.status !== 'matched' || !claimed.document) {
401
+ throw new RelyingPartyError('invalid_or_replayed_login');
402
+ }
403
+ const attempt = claimed.document as RelyingPartyLoginAttemptDoc;
404
+
405
+ let completion: IOidcCompletion | undefined;
406
+ let stage: TLoginCallbackStage = 'token_exchange';
407
+ try {
408
+ completion = await provider.completeAuthorization({
409
+ callbackUrl: callbackUrlArg,
410
+ state: states[0],
411
+ nonce: attempt.nonce,
412
+ pkceVerifier: this.#secretBox.open(attempt.sealedPkceVerifier),
413
+ });
414
+ stage = 'principal_admission';
415
+ const { principalId } = await this.#principals.admit(completion);
416
+ stage = 'session_persistence';
417
+ return await this.createProviderSession(attempt, principalId, completion);
418
+ } catch (error) {
419
+ if (completion) {
420
+ await this.revokeCompletionTokens(completion);
421
+ }
422
+ if (error instanceof RelyingPartyError || error instanceof RelyingPartyRefusal) {
423
+ throw error;
424
+ }
425
+ if (error instanceof OidcCompletionError) {
426
+ throw new LoginCallbackError(error.stage, error);
427
+ }
428
+ throw new LoginCallbackError(stage, error);
429
+ }
430
+ }
431
+
432
+ /**
433
+ * The provider sent the browser back without a code. The attempt is
434
+ * consumed so its state can never complete afterwards; a callback that
435
+ * matches no open attempt leaves nothing to do.
436
+ */
437
+ public async abandonLogin(callbackUrlArg: URL, cookieHeaderArg: string | null | undefined): Promise<void> {
438
+ const states = callbackUrlArg.searchParams.getAll('state');
439
+ const bindingToken = parseCookies(cookieHeaderArg).get(this.intentCookieName);
440
+ if (
441
+ states.length !== 1
442
+ || !states[0]
443
+ || !bindingToken
444
+ || bindingToken.length > MAX_COOKIE_TOKEN_LENGTH
445
+ ) {
446
+ return;
447
+ }
448
+ await this.#models.LoginAttempt.atomicUpdate(
449
+ { stateHash: sha256(states[0]), bindingTokenHash: sha256(bindingToken), consumedAt: null },
450
+ { $set: { consumedAt: this.#now() } },
451
+ );
452
+ }
453
+
454
+ /**
455
+ * The last step of a login: the browser arrives with both cookies, and the
456
+ * return path is released only when the session cookie belongs to the
457
+ * session this very attempt created.
458
+ */
459
+ public async finalizeLogin(cookieHeaderArg: string | null | undefined): Promise<string> {
460
+ this.requireProvider();
461
+ const bindingToken = parseCookies(cookieHeaderArg).get(this.intentCookieName);
462
+ if (!bindingToken || bindingToken.length > MAX_COOKIE_TOKEN_LENGTH) {
463
+ throw new RelyingPartyError('invalid_login_finalize');
464
+ }
465
+ const attempt = await this.#models.LoginAttempt.getInstance({ bindingTokenHash: sha256(bindingToken) });
466
+ if (
467
+ !attempt
468
+ || attempt.consumedAt === undefined
469
+ || attempt.consumedAt === null
470
+ || !attempt.createdSessionId
471
+ || attempt.expiresAt <= this.#now()
472
+ ) {
473
+ throw new RelyingPartyError('invalid_login_finalize');
474
+ }
475
+ const resolved = await this.resolveCookieSession(cookieHeaderArg);
476
+ if (
477
+ !resolved
478
+ || resolved.session.id !== attempt.createdSessionId
479
+ || resolved.session.loginAttemptId !== attempt.id
480
+ ) {
481
+ throw new RelyingPartyError('invalid_login_finalize');
482
+ }
483
+ return sanitizeReturnPath(attempt.returnPath, this.publicOrigin, this.loginPathPrefix);
484
+ }
485
+
486
+ // ==========================================================================
487
+ // provider tokens
488
+ // ==========================================================================
489
+
490
+ /**
491
+ * An access token of this login session that is usable right now. A token
492
+ * within `ACCESS_TOKEN_REFRESH_SKEW_MS` of its expiry — or one whose expiry
493
+ * is unknown — is rotated first. The session has to belong to
494
+ * `expectedPrincipalIdArg`, so a credential is never evaluated with another
495
+ * person's tokens.
496
+ */
497
+ public async liveProviderToken(sessionIdArg: string, expectedPrincipalIdArg: string): Promise<IProviderToken> {
498
+ const session = await this.#models.Session.getInstance({ id: sessionIdArg });
499
+ const now = this.#now();
500
+ if (
501
+ !session
502
+ || session.revokedAt !== undefined
503
+ || session.expiresAt <= now
504
+ || !session.idpSubject
505
+ || session.userId !== expectedPrincipalIdArg
506
+ ) {
507
+ throw new AuthorityTokenError('authority_login_required');
508
+ }
509
+ const subject = session.idpSubject;
510
+ const expiresAt = typeof session.accessTokenExpiresAt === 'number' ? session.accessTokenExpiresAt : 0;
511
+ const accessToken = this.openSealed(session.sealedAccessToken);
512
+ // an unknown expiry is rotated too: afterwards the lifetime is known
513
+ const due = accessToken === undefined || expiresAt === 0 || expiresAt <= now + ACCESS_TOKEN_REFRESH_SKEW_MS;
514
+ if (!due && accessToken !== undefined) {
515
+ return { accessToken, subject, expiresAt };
516
+ }
517
+ if (!session.sealedRefreshToken) {
518
+ if (accessToken !== undefined && (expiresAt === 0 || expiresAt > now)) {
519
+ // nothing to rotate with, and the token is not known to be dead
520
+ return { accessToken, subject, expiresAt };
521
+ }
522
+ await this.endProviderSession(sessionIdArg, 'access_token_unusable');
523
+ throw new AuthorityTokenError('authority_login_required');
524
+ }
525
+ return this.refreshProviderToken(sessionIdArg, subject);
526
+ }
527
+
528
+ private async refreshProviderToken(sessionIdArg: string, subjectArg: string): Promise<IProviderToken> {
529
+ const running = this.#refreshesInFlight.get(sessionIdArg);
530
+ if (running) {
531
+ return running;
532
+ }
533
+ const pending = this.runProviderRefresh(sessionIdArg, subjectArg).finally(() => {
534
+ this.#refreshesInFlight.delete(sessionIdArg);
535
+ });
536
+ this.#refreshesInFlight.set(sessionIdArg, pending);
537
+ return pending;
538
+ }
539
+
540
+ private async runProviderRefresh(sessionIdArg: string, subjectArg: string): Promise<IProviderToken> {
541
+ const provider = this.#provider;
542
+ if (!provider) {
543
+ throw new AuthorityTokenError('authority_unavailable');
544
+ }
545
+ const now = this.#now();
546
+ const lockUntil = now + this.refreshLockMs;
547
+ const claimed = await this.#models.Session.atomicFindOneAndUpdate(
548
+ {
549
+ id: sessionIdArg,
550
+ revokedAt: { $exists: false },
551
+ expiresAt: { $gt: now },
552
+ // a lock another replica holds is left alone until it expires
553
+ $or: [{ refreshLockUntil: { $lt: now } }, { refreshLockUntil: { $exists: false } }],
554
+ },
555
+ { $set: { refreshLockUntil: lockUntil } },
556
+ { returnDocument: 'after' },
557
+ );
558
+ if (claimed.status !== 'matched' || !claimed.document) {
559
+ return this.tokenAfterForeignRefresh(sessionIdArg, subjectArg);
560
+ }
561
+ const session = claimed.document as RelyingPartySessionDoc;
562
+ // the lock holder decides on the state it just read: a rotation that
563
+ // landed in between is used as it stands, because spending a superseded
564
+ // refresh token would be reuse and would kill the family
565
+ const claimedExpiry = typeof session.accessTokenExpiresAt === 'number' ? session.accessTokenExpiresAt : 0;
566
+ const claimedToken = this.openSealed(session.sealedAccessToken);
567
+ if (claimedToken !== undefined && claimedExpiry > this.#now() + ACCESS_TOKEN_REFRESH_SKEW_MS) {
568
+ await this.#models.Session.atomicUpdate(
569
+ { id: sessionIdArg, revokedAt: { $exists: false }, refreshLockUntil: lockUntil },
570
+ { $set: { refreshLockUntil: 0 } },
571
+ );
572
+ return { accessToken: claimedToken, subject: subjectArg, expiresAt: claimedExpiry };
573
+ }
574
+ const refreshToken = this.openSealed(session.sealedRefreshToken);
575
+ if (refreshToken === undefined) {
576
+ await this.endProviderSession(sessionIdArg, 'sealed_refresh_token_unreadable');
577
+ throw new AuthorityTokenError('authority_login_required');
578
+ }
579
+ let rotated: IOidcRefreshedTokens;
580
+ try {
581
+ rotated = await provider.refreshTokens(refreshToken);
582
+ } catch (error) {
583
+ if (error instanceof OidcRefreshRefusedError) {
584
+ await this.endProviderSession(sessionIdArg, 'refresh_token_refused');
585
+ throw new AuthorityTokenError('authority_login_required', error);
586
+ }
587
+ this.emit({ event: 'idp_token_refresh_failed', sessionId: sessionIdArg, message: errorMessage(error) });
588
+ throw new AuthorityTokenError('authority_unavailable', error);
589
+ }
590
+ // the rotated pair replaces the old one under this lock, and only while
591
+ // the session is live: a logout in between leaves the update unmatched
592
+ let matched: number;
593
+ try {
594
+ const written = await this.#models.Session.atomicUpdate(
595
+ { id: sessionIdArg, revokedAt: { $exists: false }, refreshLockUntil: lockUntil },
596
+ {
597
+ $set: {
598
+ sealedAccessToken: this.#secretBox.seal(rotated.accessToken),
599
+ ...(rotated.refreshToken ? { sealedRefreshToken: this.#secretBox.seal(rotated.refreshToken) } : {}),
600
+ accessTokenExpiresAt: rotated.accessTokenExpiresAt,
601
+ refreshLockUntil: 0,
602
+ },
603
+ },
604
+ );
605
+ matched = written.matchedCount;
606
+ } catch (error) {
607
+ // the provider has superseded the old pair and nothing here can name the
608
+ // new one: it goes back at once, the lock expires on its own, and the
609
+ // request fails closed
610
+ await this.revokeRotatedTokens(rotated);
611
+ this.emit({ event: 'idp_token_store_failed', sessionId: sessionIdArg, message: errorMessage(error) });
612
+ throw new AuthorityTokenError('authority_unavailable', error);
613
+ }
614
+ if (matched === 0) {
615
+ await this.revokeRotatedTokens(rotated);
616
+ this.notifySessionEnded({ sessionId: sessionIdArg, reason: 'revoked_elsewhere', revokedHere: false });
617
+ throw new AuthorityTokenError('authority_login_required');
618
+ }
619
+ return { accessToken: rotated.accessToken, subject: subjectArg, expiresAt: rotated.accessTokenExpiresAt };
620
+ }
621
+
622
+ /**
623
+ * Another replica is rotating this session right now. Whatever it has
624
+ * written by the time this request looks again is used; otherwise the
625
+ * request fails closed and the next one finds the rotated pair.
626
+ */
627
+ private async tokenAfterForeignRefresh(sessionIdArg: string, subjectArg: string): Promise<IProviderToken> {
628
+ const session = await this.#models.Session.getInstance({ id: sessionIdArg });
629
+ const now = this.#now();
630
+ const expiresAt = typeof session?.accessTokenExpiresAt === 'number' ? session.accessTokenExpiresAt : 0;
631
+ const accessToken = session ? this.openSealed(session.sealedAccessToken) : undefined;
632
+ if (
633
+ session
634
+ && session.revokedAt === undefined
635
+ && session.expiresAt > now
636
+ && accessToken !== undefined
637
+ && expiresAt > now + ACCESS_TOKEN_REFRESH_SKEW_MS
638
+ ) {
639
+ return { accessToken, subject: subjectArg, expiresAt };
640
+ }
641
+ throw new AuthorityTokenError('authority_unavailable');
642
+ }
643
+
644
+ // ==========================================================================
645
+ // internals
646
+ // ==========================================================================
647
+
648
+ private requireProvider(): IOidcProvider {
649
+ if (!this.#provider) {
650
+ throw new RelyingPartyError('login_method_unavailable');
651
+ }
652
+ return this.#provider;
653
+ }
654
+
655
+ private newSessionDoc(tokenArg: string, principalIdArg: string): RelyingPartySessionDoc {
656
+ const now = this.#now();
657
+ const session = new this.#models.Session();
658
+ session.id = 'ses_' + randomToken(18);
659
+ session.tokenHash = sha256(tokenArg);
660
+ session.userId = principalIdArg;
661
+ session.createdAt = now;
662
+ session.lastSeenAt = now;
663
+ session.expiresAt = now + this.sessionMaxAgeSeconds * 1_000;
664
+ return session;
665
+ }
666
+
667
+ private csrfTokenOf(tokenArg: string): string {
668
+ return deriveCsrfToken(tokenArg, this.#csrfKey, this.#csrfLabel);
669
+ }
670
+
671
+ private opening(sessionArg: RelyingPartySessionDoc, tokenArg: string): IRelyingPartySessionOpening {
672
+ return {
673
+ session: this.snapshot(sessionArg),
674
+ csrfToken: this.csrfTokenOf(tokenArg),
675
+ sessionCookie: serializeCookie(this.sessionCookieName, tokenArg, {
676
+ httpOnly: true,
677
+ secure: this.secureCookies,
678
+ sameSite: 'Lax',
679
+ maxAgeSeconds: this.sessionMaxAgeSeconds,
680
+ }),
681
+ };
682
+ }
683
+
684
+ private snapshot(sessionArg: RelyingPartySessionDoc): IRelyingPartySession {
685
+ return {
686
+ id: sessionArg.id,
687
+ principalId: sessionArg.userId,
688
+ createdAt: sessionArg.createdAt,
689
+ lastSeenAt: sessionArg.lastSeenAt,
690
+ expiresAt: sessionArg.expiresAt,
691
+ ...(sessionArg.idpSubject ? { idpSubject: sessionArg.idpSubject } : {}),
692
+ ...(sessionArg.loginAttemptId ? { loginAttemptId: sessionArg.loginAttemptId } : {}),
693
+ };
694
+ }
695
+
696
+ /** A session document that is live right now with an active principal, touched at most once a minute. */
697
+ private async liveSession(sessionArg: RelyingPartySessionDoc | null): Promise<RelyingPartySessionDoc | null> {
698
+ const now = this.#now();
699
+ if (!sessionArg || sessionArg.revokedAt !== undefined || sessionArg.expiresAt <= now) {
700
+ return null;
701
+ }
702
+ if (!(await this.#principals.isActive(sessionArg.userId))) {
703
+ return null;
704
+ }
705
+ if (now - sessionArg.lastSeenAt > SESSION_TOUCH_INTERVAL_MS) {
706
+ // the touch carries the liveness it was read under into the write: a
707
+ // revocation that landed in between leaves it unmatched
708
+ const touched = await this.#models.Session.atomicUpdate(
709
+ { id: sessionArg.id, revokedAt: { $exists: false }, expiresAt: { $gt: now } },
710
+ { $set: { lastSeenAt: now } },
711
+ );
712
+ if (touched.matchedCount === 0) {
713
+ return null;
714
+ }
715
+ sessionArg.lastSeenAt = now;
716
+ }
717
+ return sessionArg;
718
+ }
719
+
720
+ private async createProviderSession(
721
+ attemptArg: RelyingPartyLoginAttemptDoc,
722
+ principalIdArg: string,
723
+ completionArg: IOidcCompletion,
724
+ ): Promise<IRelyingPartySessionOpening> {
725
+ const token = randomToken(32);
726
+ const session = this.newSessionDoc(token, principalIdArg);
727
+ session.idpSubject = completionArg.subject;
728
+ session.loginAttemptId = attemptArg.id;
729
+ session.sealedAccessToken = this.#secretBox.seal(completionArg.accessToken);
730
+ session.sealedRefreshToken = completionArg.refreshToken ? this.#secretBox.seal(completionArg.refreshToken) : '';
731
+ session.accessTokenExpiresAt = completionArg.accessTokenExpiresAt;
732
+ session.refreshLockUntil = 0;
733
+ await session.save();
734
+
735
+ const linked = await this.#models.LoginAttempt.atomicUpdate(
736
+ { id: attemptArg.id, createdSessionId: null },
737
+ { $set: { createdSessionId: session.id } },
738
+ );
739
+ if (linked.matchedCount !== 1) {
740
+ // the attempt was consumed by another callback in the meantime
741
+ await this.discardUnopenedSession(session.id);
742
+ throw new RelyingPartyError('invalid_or_replayed_login');
743
+ }
744
+ const opening = this.opening(session, token);
745
+ try {
746
+ await this.#hooks.onProviderSessionOpened?.({ session: opening.session });
747
+ } catch (error) {
748
+ // the cookie never leaves, so the session is unreachable: it is revoked
749
+ // and its sealed tokens erased before the application's error goes on;
750
+ // the caller hands the provider tokens back
751
+ try {
752
+ await this.discardUnopenedSession(session.id);
753
+ } catch (discardError) {
754
+ this.emit({ event: 'session_discard_failed', sessionId: session.id, message: errorMessage(discardError) });
755
+ }
756
+ throw error;
757
+ }
758
+ return opening;
759
+ }
760
+
761
+ /** Revokes a session that was never handed out and erases its sealed tokens. */
762
+ private async discardUnopenedSession(sessionIdArg: string): Promise<void> {
763
+ await this.#models.Session.atomicUpdate(
764
+ { id: sessionIdArg, revokedAt: { $exists: false } },
765
+ { $set: { revokedAt: this.#now(), ...REVOKED_TOKEN_FIELDS } },
766
+ );
767
+ }
768
+
769
+ /**
770
+ * Tells the application a session ended. Its hook never stands between the
771
+ * local revocation and the provider revocation: a throw or a rejection is
772
+ * reported as `hook_failed` and the end runs on.
773
+ */
774
+ private notifySessionEnded(eventArg: {
775
+ sessionId: string;
776
+ reason: TRelyingPartySessionEndReason;
777
+ revokedHere: boolean;
778
+ }): void {
779
+ const hook = this.#hooks.onSessionEnded;
780
+ if (!hook) {
781
+ return;
782
+ }
783
+ const reportFailure = (errorArg: unknown) => {
784
+ this.emit({ event: 'hook_failed', hook: 'onSessionEnded', sessionId: eventArg.sessionId, message: errorMessage(errorArg) });
785
+ };
786
+ try {
787
+ const result: unknown = hook.call(this.#hooks, eventArg);
788
+ if (result instanceof Promise) {
789
+ result.catch(reportFailure);
790
+ }
791
+ } catch (error) {
792
+ reportFailure(error);
793
+ }
794
+ }
795
+
796
+ /**
797
+ * The conditional revocation every end of a session runs. Answers the
798
+ * sealed tokens it took off the session when this call revoked it, `null`
799
+ * when the session was already over.
800
+ */
801
+ private async revokeSession(
802
+ sessionIdArg: string,
803
+ reasonArg: TRelyingPartySessionEndReason,
804
+ ): Promise<TSealedTokens | null> {
805
+ if (typeof sessionIdArg !== 'string' || sessionIdArg.length === 0) {
806
+ return null;
807
+ }
808
+ const revoked = await this.#models.Session.atomicFindOneAndUpdate(
809
+ { id: sessionIdArg, revokedAt: { $exists: false } },
810
+ { $set: { revokedAt: this.#now(), ...REVOKED_TOKEN_FIELDS } },
811
+ { returnDocument: 'before' },
812
+ );
813
+ const revokedHere = revoked.status === 'matched' && revoked.document !== null;
814
+ this.notifySessionEnded({ sessionId: sessionIdArg, reason: reasonArg, revokedHere });
815
+ if (!revokedHere) {
816
+ return null;
817
+ }
818
+ const before = revoked.document as RelyingPartySessionDoc;
819
+ return { sealedAccessToken: before.sealedAccessToken, sealedRefreshToken: before.sealedRefreshToken };
820
+ }
821
+
822
+ /** The provider says the login is over: the session ends here without asking it anything more. */
823
+ private async endProviderSession(sessionIdArg: string, reasonArg: TRelyingPartySessionEndReason): Promise<void> {
824
+ if (await this.revokeSession(sessionIdArg, reasonArg)) {
825
+ this.emit({ event: 'idp_session_ended', sessionId: sessionIdArg, reason: reasonArg });
826
+ }
827
+ }
828
+
829
+ /**
830
+ * Hands the tokens of an ended session back, best-effort and within the
831
+ * budget. A sealed value this deployment can no longer open is skipped.
832
+ */
833
+ private async revokeSealedTokens(sealedArg: TSealedTokens): Promise<void> {
834
+ if (!this.#provider || (!sealedArg.sealedAccessToken && !sealedArg.sealedRefreshToken)) {
835
+ return;
836
+ }
837
+ await this.withinRevocationBudget(async () => {
838
+ for (const [sealed, hint] of [
839
+ [sealedArg.sealedRefreshToken, 'refresh_token'],
840
+ [sealedArg.sealedAccessToken, 'access_token'],
841
+ ] as const) {
842
+ if (!sealed) {
843
+ continue;
844
+ }
845
+ const token = this.openSealed(sealed);
846
+ if (token === undefined) {
847
+ this.emit({ event: 'oidc_sealed_token_unreadable', hint, message: 'The sealed provider token could not be opened' });
848
+ continue;
849
+ }
850
+ await this.revokeToken(token, hint);
851
+ }
852
+ });
853
+ }
854
+
855
+ private async revokeRotatedTokens(rotatedArg: IOidcRefreshedTokens): Promise<void> {
856
+ await this.withinRevocationBudget(async () => {
857
+ if (rotatedArg.refreshToken) {
858
+ await this.revokeToken(rotatedArg.refreshToken, 'refresh_token');
859
+ }
860
+ await this.revokeToken(rotatedArg.accessToken, 'access_token');
861
+ });
862
+ }
863
+
864
+ private async revokeCompletionTokens(completionArg: IOidcCompletion): Promise<void> {
865
+ await this.withinRevocationBudget(async () => {
866
+ if (completionArg.refreshToken) {
867
+ await this.revokeToken(completionArg.refreshToken, 'refresh_token');
868
+ }
869
+ await this.revokeToken(completionArg.accessToken, 'access_token');
870
+ });
871
+ }
872
+
873
+ private async revokeToken(tokenArg: string, hintArg: TOidcTokenHint): Promise<void> {
874
+ try {
875
+ await this.#provider?.revokeToken(tokenArg, hintArg);
876
+ } catch (error) {
877
+ // the local session is gone either way; the provider expires the token
878
+ this.emit({ event: 'oidc_token_revocation_failed', hint: hintArg, message: errorMessage(error) });
879
+ }
880
+ }
881
+
882
+ /**
883
+ * Runs a best-effort provider step under `TOKEN_REVOCATION_BUDGET_MS`. What
884
+ * is still in flight at the deadline keeps running and ends on the provider
885
+ * client's own request timeout; only the waiting stops. The timer is cleared
886
+ * on every path and unreferenced.
887
+ */
888
+ private async withinRevocationBudget(workArg: () => Promise<void>): Promise<void> {
889
+ let timer: ReturnType<typeof setTimeout> | undefined;
890
+ const work = workArg().then(
891
+ () => 'done' as const,
892
+ (errorArg: unknown) => {
893
+ this.emit({ event: 'oidc_token_revocation_failed', message: errorMessage(errorArg) });
894
+ return 'done' as const;
895
+ },
896
+ );
897
+ const deadline = new Promise<'deadline'>((resolveArg) => {
898
+ timer = setTimeout(() => resolveArg('deadline'), TOKEN_REVOCATION_BUDGET_MS);
899
+ timer.unref();
900
+ });
901
+ try {
902
+ if ((await Promise.race([work, deadline])) === 'deadline') {
903
+ this.emit({
904
+ event: 'oidc_token_revocation_timed_out',
905
+ message: 'The provider did not answer within the revocation budget',
906
+ });
907
+ }
908
+ } finally {
909
+ clearTimeout(timer);
910
+ }
911
+ }
912
+
913
+ /** A sealed value this deployment can still open, or `undefined`. */
914
+ private openSealed(sealedArg: string | undefined): string | undefined {
915
+ if (!sealedArg) {
916
+ return undefined;
917
+ }
918
+ try {
919
+ return this.#secretBox.open(sealedArg);
920
+ } catch {
921
+ return undefined;
922
+ }
923
+ }
924
+
925
+ /**
926
+ * Removes attempts an hour past their expiry, one bounded page per login
927
+ * start. A sweep that cannot run never blocks a sign-in.
928
+ */
929
+ private async sweepExpiredAttempts(): Promise<void> {
930
+ try {
931
+ const page = await this.#models.LoginAttempt.getPagedInstances({
932
+ filter: { expiresAt: { $lt: this.#now() - LOGIN_ATTEMPT_SWEEP_GRACE_MS } },
933
+ sortField: 'expiresAt',
934
+ sortDirection: 'asc',
935
+ limit: LOGIN_ATTEMPT_SWEEP_BATCH,
936
+ });
937
+ for (const attempt of page.documents) {
938
+ await this.#models.LoginAttempt.atomicDelete({ id: attempt.id });
939
+ }
940
+ } catch (error) {
941
+ this.emit({ event: 'login_attempt_sweep_failed', message: errorMessage(error) });
942
+ }
943
+ }
944
+
945
+ /**
946
+ * Reports one diagnostic. Every cleanup step reports through here, so a
947
+ * throwing sink must not end it: the event and the sink's failure go to
948
+ * `console.error` instead.
949
+ */
950
+ private emit(eventArg: IRelyingPartyEvent): void {
951
+ const sink = this.#hooks.onEvent;
952
+ if (!sink) {
953
+ console.error(JSON.stringify(eventArg));
954
+ return;
955
+ }
956
+ const reportFailure = (errorArg: unknown) => {
957
+ const failure: IRelyingPartyEvent = { event: 'hook_failed', hook: 'onEvent', message: errorMessage(errorArg) };
958
+ console.error(JSON.stringify(failure));
959
+ console.error(JSON.stringify(eventArg));
960
+ };
961
+ try {
962
+ const result: unknown = sink.call(this.#hooks, eventArg);
963
+ if (result instanceof Promise) {
964
+ result.catch(reportFailure);
965
+ }
966
+ } catch (error) {
967
+ reportFailure(error);
968
+ }
969
+ }
970
+ }