@idp.global/sdk 19.3.0 → 19.5.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 (38) hide show
  1. package/changelog.md +66 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts_browser/classes.idpclient.js +1 -1
  4. package/dist_ts_browser/classes.idprequests.js +1 -1
  5. package/dist_ts_browser/classes.relyingpartybearerclient.d.ts +313 -0
  6. package/dist_ts_browser/classes.relyingpartybearerclient.js +646 -0
  7. package/dist_ts_browser/index.d.ts +1 -0
  8. package/dist_ts_browser/index.js +2 -1
  9. package/dist_ts_browser/plugins.js +1 -1
  10. package/dist_ts_browser/secret-delivery.js +1 -1
  11. package/dist_ts_relyingparty/classes.authorityobserver.d.ts +23 -0
  12. package/dist_ts_relyingparty/classes.authorityobserver.js +68 -13
  13. package/dist_ts_relyingparty/classes.errors.d.ts +5 -1
  14. package/dist_ts_relyingparty/classes.errors.js +3 -1
  15. package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +12 -0
  16. package/dist_ts_relyingparty/classes.relyingpartyroutes.js +33 -6
  17. package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +161 -11
  18. package/dist_ts_relyingparty/classes.relyingpartysessions.js +381 -55
  19. package/dist_ts_relyingparty/classes.rpmodels.d.ts +6 -4
  20. package/dist_ts_relyingparty/classes.rpmodels.js +8 -6
  21. package/dist_ts_relyingparty/contract.bearer.d.ts +119 -0
  22. package/dist_ts_relyingparty/contract.bearer.js +13 -0
  23. package/dist_ts_relyingparty/helpers.bearer.d.ts +3 -4
  24. package/dist_ts_relyingparty/helpers.bearer.js +4 -6
  25. package/dist_ts_relyingparty/interfaces.d.ts +75 -26
  26. package/package.json +2 -1
  27. package/readme.md +363 -21
  28. package/ts/00_commitinfo_data.ts +1 -1
  29. package/ts_browser/classes.relyingpartybearerclient.ts +901 -0
  30. package/ts_browser/index.ts +1 -0
  31. package/ts_relyingparty/classes.authorityobserver.ts +80 -12
  32. package/ts_relyingparty/classes.errors.ts +7 -1
  33. package/ts_relyingparty/classes.relyingpartyroutes.ts +38 -7
  34. package/ts_relyingparty/classes.relyingpartysessions.ts +463 -65
  35. package/ts_relyingparty/classes.rpmodels.ts +7 -4
  36. package/ts_relyingparty/contract.bearer.ts +145 -0
  37. package/ts_relyingparty/helpers.bearer.ts +10 -14
  38. package/ts_relyingparty/interfaces.ts +93 -33
@@ -39,6 +39,7 @@ import type {
39
39
  IProviderToken,
40
40
  IRelyingPartyBearerDelivery,
41
41
  IRelyingPartyBearerHandoff,
42
+ IRelyingPartyBearerHandoffOpening,
42
43
  IRelyingPartyBearerLoginRequest,
43
44
  IRelyingPartyBearerLoginStart,
44
45
  IRelyingPartyBearerOptions,
@@ -52,6 +53,9 @@ import type {
52
53
  IRelyingPartyLoginStart,
53
54
  IRelyingPartySession,
54
55
  IRelyingPartySessionOpening,
56
+ IRelyingPartySessionsErasure,
57
+ IRelyingPartySessionsPage,
58
+ IRelyingPartySessionsPageOptions,
55
59
  IResolvedRelyingPartySession,
56
60
  TLoginCallbackStage,
57
61
  TRelyingPartyPrincipals,
@@ -69,10 +73,16 @@ const SESSION_TOUCH_INTERVAL_MS = 60_000;
69
73
  export const LOGIN_ATTEMPT_MAX_AGE_SECONDS = 10 * 60;
70
74
  /** The longest intent a login carries (`IRelyingPartyLoginOptions.intent`), in characters. */
71
75
  export const RELYING_PARTY_MAX_INTENT_LENGTH = 4_096;
72
- /** Attempts — and bearer hand-offs and token generations — are swept once they are this much older than their expiry. */
76
+ /** Attempts — and bearer hand-offs, token generations and sessions — are swept once they are this much older than their expiry. */
73
77
  const LOGIN_ATTEMPT_SWEEP_GRACE_MS = 60 * 60 * 1_000;
74
78
  /** How many of them one sign-in removes; the next sign-in takes the following page. */
75
79
  const LOGIN_ATTEMPT_SWEEP_BATCH = 100;
80
+ /** How many sessions one page of `sessionsOfPrincipal` holds unless its `limit` says otherwise. */
81
+ export const RELYING_PARTY_DEFAULT_SESSIONS_PAGE_LIMIT = 100;
82
+ /** The largest `limit` of `sessionsOfPrincipal`; the store reads no larger page. */
83
+ export const RELYING_PARTY_MAX_SESSIONS_PAGE_LIMIT = 1_000;
84
+ /** A `sessionsOfPrincipal` cursor is a short base64url text; anything far longer is none. */
85
+ const MAX_SESSIONS_CURSOR_LENGTH = 512;
76
86
  /** How long a bearer hand-off code can be claimed, at most; never past its session. */
77
87
  export const BEARER_HANDOFF_MAX_AGE_SECONDS = 60;
78
88
  /** How long a bearer access token lives unless `bearer.accessTokenMaxAgeSeconds` says otherwise. */
@@ -107,6 +117,12 @@ export interface IRelyingPartySessionsOptions<TPrincipal extends object | undefi
107
117
  sessionEncryptionKey: Buffer;
108
118
  /** 32 bytes; the CSRF HMAC key */
109
119
  csrfKey: Buffer;
120
+ /**
121
+ * `true` (the default): cookie sessions with a session-bound CSRF token,
122
+ * beside bearer hand-off mode when `bearer` is given. Bearer hand-off alone,
123
+ * without any cookie, is `IRelyingPartyBearerOnlySessionsOptions`.
124
+ */
125
+ cookieSessions?: true;
110
126
  /** the label the CSRF HMAC starts with; an application with live sessions keeps its own */
111
127
  csrfLabel?: string;
112
128
  sessionMaxAgeSeconds?: number;
@@ -131,8 +147,36 @@ export interface IRelyingPartySessionsOptions<TPrincipal extends object | undefi
131
147
  now?: () => number;
132
148
  }
133
149
 
150
+ /**
151
+ * Bearer-only mode (`cookieSessions: false`): the application's own clients
152
+ * sign in through bearer hand-off and nothing else. The relying party never
153
+ * reads or sets a cookie — not even the clearing intent cookie of a callback
154
+ * that is no bearer login — so it has no CSRF token and takes no CSRF key:
155
+ * `csrfKey`, `csrfLabel` and `cookieNames` are refused, not ignored. The
156
+ * cookie steps (`openSession`, `startLogin`, `completeLogin`, `abandonLogin`,
157
+ * `finalizeLogin`, `clearSessionCookie`, `clearIntentCookie`) throw
158
+ * `RelyingPartyError('login_method_unavailable')` and `resolveCookieSession`
159
+ * answers `null`.
160
+ */
161
+ export interface IRelyingPartyBearerOnlySessionsOptions<TPrincipal extends object | undefined = undefined>
162
+ extends Omit<IRelyingPartySessionsOptions<TPrincipal>, 'provider' | 'csrfKey' | 'csrfLabel' | 'cookieNames' | 'cookieSessions' | 'bearer'> {
163
+ cookieSessions: false;
164
+ /** every bearer session comes from a live provider login, directly or through a hand-off opened from one */
165
+ provider: IOidcProvider;
166
+ bearer: IRelyingPartyBearerOptions;
167
+ csrfKey?: never;
168
+ csrfLabel?: never;
169
+ cookieNames?: never;
170
+ }
171
+
134
172
  type TSealedTokens = Pick<RelyingPartySessionDoc, 'sealedAccessToken' | 'sealedRefreshToken'>;
135
173
 
174
+ /** Cookie sessions as the constructor checked them; absent in bearer-only mode. */
175
+ interface ICookieSessionsMode {
176
+ csrfKey: Buffer;
177
+ csrfLabel: string;
178
+ }
179
+
136
180
  /** Bearer hand-off mode as the constructor checked it. */
137
181
  interface IBearerMode {
138
182
  returnTargets: Readonly<Required<IRelyingPartyBearerReturnTargets>>;
@@ -148,7 +192,7 @@ const bearerModeOf = (
148
192
  throw new Error('bearer must be an object with returnTargets');
149
193
  }
150
194
  if (!optionsArg.hasProvider) {
151
- throw new Error('bearer needs a provider: every bearer session is a provider login');
195
+ throw new Error('bearer needs a provider: every bearer session comes from a provider login');
152
196
  }
153
197
  const { BearerHandoff, BearerToken } = optionsArg.models;
154
198
  if (!BearerHandoff || !BearerToken) {
@@ -180,6 +224,43 @@ const singleStateOf = (callbackUrlArg: URL): string | null => {
180
224
  const bearerCredentialOf = (valueArg: unknown): string | null =>
181
225
  typeof valueArg === 'string' && valueArg.length > 0 && valueArg.length <= MAX_BEARER_TOKEN_LENGTH ? valueArg : null;
182
226
 
227
+ /** Where a `sessionsOfPrincipal` page ends: its last session's `createdAt` and `id`. */
228
+ interface ISessionsCursor {
229
+ sortValue: number;
230
+ uniqueValue: string;
231
+ }
232
+
233
+ const serializeSessionsCursor = (cursorArg: { sortValue: number | string; uniqueValue: string }): string =>
234
+ Buffer.from(JSON.stringify([cursorArg.sortValue, cursorArg.uniqueValue]), 'utf8').toString('base64url');
235
+
236
+ /**
237
+ * The position a cursor of `sessionsOfPrincipal` names. It seeks within the
238
+ * principal's own sessions only, so it needs no seal; anything that is not
239
+ * such a position is refused.
240
+ */
241
+ const parseSessionsCursor = (cursorArg: unknown): ISessionsCursor => {
242
+ if (typeof cursorArg !== 'string' || cursorArg.length > MAX_SESSIONS_CURSOR_LENGTH || !/^[A-Za-z0-9_-]+$/.test(cursorArg)) {
243
+ throw new RelyingPartyError('invalid_sessions_cursor');
244
+ }
245
+ let position: unknown;
246
+ try {
247
+ position = JSON.parse(Buffer.from(cursorArg, 'base64url').toString('utf8'));
248
+ } catch {
249
+ throw new RelyingPartyError('invalid_sessions_cursor');
250
+ }
251
+ if (
252
+ !Array.isArray(position)
253
+ || position.length !== 2
254
+ || !Number.isSafeInteger(position[0])
255
+ || position[0] < 0
256
+ || typeof position[1] !== 'string'
257
+ || position[1] === ''
258
+ ) {
259
+ throw new RelyingPartyError('invalid_sessions_cursor');
260
+ }
261
+ return { sortValue: position[0], uniqueValue: position[1] };
262
+ };
263
+
183
264
  const REVOKED_TOKEN_FIELDS = { sealedAccessToken: '', sealedRefreshToken: '', refreshLockUntil: 0 };
184
265
 
185
266
  const errorMessage = (errorArg: unknown): string => (errorArg instanceof Error ? errorArg.message : String(errorArg));
@@ -211,10 +292,17 @@ const cookieNameFor = (
211
292
  * token, provider tokens sealed at rest and rotated under a cross-replica
212
293
  * lock, and session ends that hand the tokens back.
213
294
  *
214
- * Nothing here runs on a timer: expired login attempts are swept when the next
215
- * login starts, and the only timer — the revocation budget — is unreferenced
216
- * and cleared on every path. Nothing here does I/O while it is constructed,
217
- * so it is built before the database connects.
295
+ * With `cookieSessions: false` it runs bearer hand-off mode alone and never
296
+ * touches a cookie (`IRelyingPartyBearerOnlySessionsOptions`).
297
+ *
298
+ * Nothing here runs on a timer: expired login attempts, bearer state and
299
+ * ended sessions are swept one bounded batch at a time when a login starts or
300
+ * the application opens a session, and the only timer — the revocation
301
+ * budget — is unreferenced and cleared on every path. Every session comes
302
+ * from a login start, `openSession` or `openBearerHandoff`, and each of those
303
+ * sweeps a batch of at least one first, so removal keeps pace with creation
304
+ * and the ended sessions kept stay bounded. Nothing here does I/O while it is
305
+ * constructed, so it is built before the database connects.
218
306
  *
219
307
  * `TPrincipal` is what a `principals.resolve` answers; with `isActive`
220
308
  * principals it stays `undefined`.
@@ -233,20 +321,34 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
233
321
  readonly #provider?: IOidcProvider;
234
322
  readonly #bearer?: IBearerMode;
235
323
  readonly #secretBox: SecretBox;
236
- readonly #csrfKey: Buffer;
237
- readonly #csrfLabel: string;
324
+ readonly #cookieSessions: ICookieSessionsMode | null;
238
325
  readonly #hooks: IRelyingPartyHooks;
239
326
  readonly #now: () => number;
240
327
  /** one rotation per session in this process, however many requests need it at once */
241
328
  readonly #refreshesInFlight = new Map<string, Promise<IProviderToken>>();
242
329
 
243
- constructor(optionsArg: IRelyingPartySessionsOptions<TPrincipal>) {
330
+ constructor(optionsArg: IRelyingPartySessionsOptions<TPrincipal> | IRelyingPartyBearerOnlySessionsOptions<TPrincipal>) {
244
331
  const origin = new URL(optionsArg.publicOrigin);
245
332
  if (origin.origin !== optionsArg.publicOrigin) {
246
333
  throw new Error('publicOrigin must be a bare origin such as https://app.example');
247
334
  }
248
- if (!Buffer.isBuffer(optionsArg.csrfKey) || optionsArg.csrfKey.length !== 32) {
249
- throw new Error('csrfKey must be 32 bytes');
335
+ const cookieSessions: unknown = optionsArg.cookieSessions ?? true;
336
+ if (cookieSessions !== true && cookieSessions !== false) {
337
+ throw new Error('cookieSessions must be true or false');
338
+ }
339
+ if (cookieSessions) {
340
+ if (!Buffer.isBuffer(optionsArg.csrfKey) || optionsArg.csrfKey.length !== 32) {
341
+ throw new Error('csrfKey must be 32 bytes');
342
+ }
343
+ } else {
344
+ for (const name of ['csrfKey', 'csrfLabel', 'cookieNames'] as const) {
345
+ if (optionsArg[name] !== undefined) {
346
+ throw new Error(`${name} has no use with cookieSessions: false, which reads and sets no cookie`);
347
+ }
348
+ }
349
+ if (optionsArg.bearer === undefined) {
350
+ throw new Error('cookieSessions: false needs bearer: without cookies, bearer hand-off is the only way in');
351
+ }
250
352
  }
251
353
  const maxAge = optionsArg.sessionMaxAgeSeconds ?? RELYING_PARTY_DEFAULT_SESSION_MAX_AGE_SECONDS;
252
354
  if (!Number.isSafeInteger(maxAge) || maxAge < 1) {
@@ -312,8 +414,9 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
312
414
  });
313
415
  }
314
416
  this.#secretBox = new SecretBox(optionsArg.sessionEncryptionKey);
315
- this.#csrfKey = Buffer.from(optionsArg.csrfKey);
316
- this.#csrfLabel = optionsArg.csrfLabel ?? RELYING_PARTY_DEFAULT_CSRF_LABEL;
417
+ this.#cookieSessions = cookieSessions
418
+ ? { csrfKey: Buffer.from(optionsArg.csrfKey), csrfLabel: optionsArg.csrfLabel ?? RELYING_PARTY_DEFAULT_CSRF_LABEL }
419
+ : null;
317
420
  this.#hooks = optionsArg.hooks ?? {};
318
421
  this.#now = optionsArg.now ?? (() => Date.now());
319
422
  }
@@ -323,6 +426,11 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
323
426
  return this.#provider !== undefined;
324
427
  }
325
428
 
429
+ /** Whether cookie sessions are offered; `false` only in bearer-only mode (`cookieSessions: false`). */
430
+ public get cookieSessionsEnabled(): boolean {
431
+ return this.#cookieSessions !== null;
432
+ }
433
+
326
434
  /** Whether bearer hand-off mode is offered. */
327
435
  public get bearerLoginEnabled(): boolean {
328
436
  return this.#bearer !== undefined;
@@ -354,6 +462,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
354
462
  * cookie with it; it is a whole number of seconds from 1 to that maximum.
355
463
  */
356
464
  public async openSession(optionsArg: { principalId: string; maxAgeSeconds?: number }): Promise<IRelyingPartySessionOpening> {
465
+ this.requireCookieSessions();
357
466
  if (typeof optionsArg?.principalId !== 'string' || optionsArg.principalId.length === 0) {
358
467
  throw new Error('openSession needs a principal id');
359
468
  }
@@ -361,16 +470,24 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
361
470
  if (!Number.isSafeInteger(maxAgeSeconds) || maxAgeSeconds < 1 || maxAgeSeconds > this.sessionMaxAgeSeconds) {
362
471
  throw new RangeError(`openSession maxAgeSeconds must be a whole number from 1 to ${this.sessionMaxAgeSeconds}`);
363
472
  }
473
+ await this.sweepEndedSessions();
364
474
  const token = randomToken(32);
365
475
  const session = this.newSessionDoc(token, optionsArg.principalId, maxAgeSeconds);
366
476
  await session.save();
367
477
  return this.opening(session, token, maxAgeSeconds);
368
478
  }
369
479
 
370
- /** The live session behind the session cookie, or `null`. A read touches it at most once a minute. */
480
+ /**
481
+ * The live session behind the session cookie, or `null`. A read touches it
482
+ * at most once a minute. Always `null` in bearer-only mode, which reads no
483
+ * cookie.
484
+ */
371
485
  public async resolveCookieSession(
372
486
  cookieHeaderArg: string | null | undefined,
373
487
  ): Promise<IResolvedRelyingPartySession<TPrincipal> | null> {
488
+ if (!this.#cookieSessions) {
489
+ return null;
490
+ }
374
491
  const token = parseCookies(cookieHeaderArg).get(this.sessionCookieName);
375
492
  if (!token || token.length > MAX_COOKIE_TOKEN_LENGTH) {
376
493
  return null;
@@ -465,7 +582,94 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
465
582
  return ended;
466
583
  }
467
584
 
585
+ /**
586
+ * Erases every session of a principal, live or ended, and every row that
587
+ * belongs to one, without waiting for a sweep: what an application runs
588
+ * when it deletes an account, after its own erasers and before its user
589
+ * record.
590
+ *
591
+ * First each session not revoked yet, an expired one too, ends as
592
+ * `endSession` ends it: its tokens stop working at once, `onSessionEnded`
593
+ * names the principal, and its provider tokens go back to idp.global,
594
+ * which a deleted row could no longer do. Then each session's rows go: in
595
+ * bearer mode its hand-offs, claimed or not, and its token generations;
596
+ * the login attempt a provider session came out of; and the session row
597
+ * last, because it is the only link from the principal to the others, so
598
+ * an erase that fails half way leaves a retry everything it needs. A
599
+ * session opened or a token issued while the erase runs is not covered;
600
+ * it works only while the principal is active.
601
+ */
602
+ public async eraseSessionsOfPrincipal(principalIdArg: string): Promise<IRelyingPartySessionsErasure> {
603
+ if (typeof principalIdArg !== 'string' || principalIdArg === '') {
604
+ throw new Error('eraseSessionsOfPrincipal needs a principal id');
605
+ }
606
+ const erasure: IRelyingPartySessionsErasure = {
607
+ endedSessions: 0,
608
+ deletedSessions: 0,
609
+ deletedLoginAttempts: 0,
610
+ deletedBearerHandoffs: 0,
611
+ deletedBearerTokens: 0,
612
+ };
613
+ const sessions = await this.#models.Session.getInstances({ userId: principalIdArg });
614
+ for (const session of sessions) {
615
+ if (session.revokedAt === undefined && (await this.endSession(session.id))) {
616
+ erasure.endedSessions += 1;
617
+ }
618
+ }
619
+ const bearerModels = this.#bearer?.models;
620
+ for (const session of sessions) {
621
+ if (bearerModels) {
622
+ erasure.deletedBearerHandoffs += (await bearerModels.BearerHandoff.atomicDeleteMany({ sessionId: session.id })).deletedCount;
623
+ erasure.deletedBearerTokens += (await bearerModels.BearerToken.atomicDeleteMany({ sessionId: session.id })).deletedCount;
624
+ }
625
+ if (session.loginAttemptId) {
626
+ erasure.deletedLoginAttempts += (await this.#models.LoginAttempt.atomicDelete({ id: session.loginAttemptId })).deletedCount;
627
+ }
628
+ erasure.deletedSessions += (await this.#models.Session.atomicDelete({ id: session.id })).deletedCount;
629
+ }
630
+ return erasure;
631
+ }
632
+
633
+ /**
634
+ * One page of a principal's live sessions, newest first: the ones neither
635
+ * ended nor expired, and none at all while the principal is not active —
636
+ * exactly the sessions `isSessionLive` confirms. Pass the page's
637
+ * `nextCursor` as `cursor` to read the next one; the last page has none.
638
+ * The cursor is opaque, and one this method did not answer is refused with
639
+ * `invalid_sessions_cursor`. A session opened while the pages are read is
640
+ * listed only when it sorts after the cursor; none is listed twice.
641
+ */
642
+ public async sessionsOfPrincipal(
643
+ principalIdArg: string,
644
+ optionsArg: IRelyingPartySessionsPageOptions = {},
645
+ ): Promise<IRelyingPartySessionsPage> {
646
+ if (typeof principalIdArg !== 'string' || principalIdArg === '') {
647
+ throw new Error('sessionsOfPrincipal needs a principal id');
648
+ }
649
+ const limit = optionsArg.limit ?? RELYING_PARTY_DEFAULT_SESSIONS_PAGE_LIMIT;
650
+ if (!Number.isInteger(limit) || limit < 1 || limit > RELYING_PARTY_MAX_SESSIONS_PAGE_LIMIT) {
651
+ throw new Error(`limit must be an integer from 1 to ${RELYING_PARTY_MAX_SESSIONS_PAGE_LIMIT}`);
652
+ }
653
+ const cursor = optionsArg.cursor === undefined ? undefined : parseSessionsCursor(optionsArg.cursor);
654
+ if (!(await this.#activePrincipal(principalIdArg))) {
655
+ return { sessions: [] };
656
+ }
657
+ const page = await this.#models.Session.getPagedInstances({
658
+ filter: { userId: principalIdArg, revokedAt: { $exists: false }, expiresAt: { $gt: this.#now() } },
659
+ sortField: 'createdAt',
660
+ sortDirection: 'desc',
661
+ uniqueField: 'id',
662
+ limit,
663
+ ...(cursor ? { cursor } : {}),
664
+ });
665
+ return {
666
+ sessions: page.documents.map((documentArg) => this.snapshot(documentArg)),
667
+ ...(page.nextCursor ? { nextCursor: serializeSessionsCursor(page.nextCursor) } : {}),
668
+ };
669
+ }
670
+
468
671
  public clearSessionCookie(): string {
672
+ this.requireCookieSessions();
469
673
  return serializeClearedCookie(this.sessionCookieName, {
470
674
  httpOnly: true,
471
675
  secure: this.secureCookies,
@@ -474,6 +678,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
474
678
  }
475
679
 
476
680
  public clearIntentCookie(): string {
681
+ this.requireCookieSessions();
477
682
  return serializeClearedCookie(this.intentCookieName, {
478
683
  httpOnly: true,
479
684
  secure: this.secureCookies,
@@ -491,9 +696,11 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
491
696
  * browser.
492
697
  */
493
698
  public async startLogin(returnPathArg: unknown, optionsArg: IRelyingPartyLoginOptions = {}): Promise<IRelyingPartyLoginStart> {
699
+ this.requireCookieSessions();
494
700
  const provider = this.requireProvider();
495
701
  this.checkLoginOptions(optionsArg);
496
702
  await this.sweepExpiredAttempts();
703
+ await this.sweepEndedSessions();
497
704
  const bindingToken = randomToken(32);
498
705
  const { attempt, state, nonce, pkceVerifier } = this.newLoginAttempt(optionsArg.intent);
499
706
  attempt.bindingTokenHash = sha256(bindingToken);
@@ -529,6 +736,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
529
736
  callbackUrlArg: URL,
530
737
  cookieHeaderArg: string | null | undefined,
531
738
  ): Promise<IRelyingPartySessionOpening> {
739
+ this.requireCookieSessions();
532
740
  const provider = this.requireProvider();
533
741
  const states = callbackUrlArg.searchParams.getAll('state');
534
742
  if (states.length !== 1 || !states[0]) {
@@ -564,6 +772,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
564
772
  * matches no open attempt leaves nothing to do.
565
773
  */
566
774
  public async abandonLogin(callbackUrlArg: URL, cookieHeaderArg: string | null | undefined): Promise<void> {
775
+ this.requireCookieSessions();
567
776
  const states = callbackUrlArg.searchParams.getAll('state');
568
777
  const bindingToken = parseCookies(cookieHeaderArg).get(this.intentCookieName);
569
778
  if (
@@ -586,6 +795,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
586
795
  * session this very attempt created.
587
796
  */
588
797
  public async finalizeLogin(cookieHeaderArg: string | null | undefined): Promise<string> {
798
+ this.requireCookieSessions();
589
799
  this.requireProvider();
590
800
  const bindingToken = parseCookies(cookieHeaderArg).get(this.intentCookieName);
591
801
  if (!bindingToken || bindingToken.length > MAX_COOKIE_TOKEN_LENGTH) {
@@ -648,6 +858,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
648
858
  this.checkLoginOptions(optionsArg);
649
859
  await this.sweepExpiredAttempts();
650
860
  await this.sweepBearerState();
861
+ await this.sweepEndedSessions();
651
862
  const { attempt, state, nonce, pkceVerifier } = this.newLoginAttempt(optionsArg.intent);
652
863
  // a binding token that never leaves: no cookie callback can consume this attempt
653
864
  attempt.bindingTokenHash = sha256(randomToken(32));
@@ -715,28 +926,85 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
715
926
  if (!isPkceChallenge(codeChallenge)) {
716
927
  throw new RelyingPartyError('invalid_or_replayed_login');
717
928
  }
718
- const code = randomToken(32);
719
- let handoff: RelyingPartyBearerHandoffDoc | undefined;
929
+ let handoff: { code: string; expiresAt: number } | undefined;
720
930
  const opened = await this.openProviderLogin(provider, callbackUrlArg, state, attempt, async (sessionArg) => {
721
- const created = new bearer.models.BearerHandoff();
722
- created.id = 'rbh_' + randomToken(18);
723
- created.codeHash = sha256(code);
724
- created.sessionId = sessionArg.id;
725
- created.codeChallenge = codeChallenge;
726
- created.createdAt = this.#now();
727
- created.expiresAt = Math.min(created.createdAt + BEARER_HANDOFF_MAX_AGE_SECONDS * 1_000, sessionArg.expiresAt);
728
- created.consumedAt = null;
729
- await created.save();
730
- handoff = created;
931
+ handoff = await this.storeBearerHandoff(bearer, sessionArg, codeChallenge);
731
932
  });
933
+ const stored = handoff as { code: string; expiresAt: number };
732
934
  return {
733
935
  session: this.snapshot(opened.document),
734
936
  delivery,
735
- code,
736
- expiresAt: (handoff as RelyingPartyBearerHandoffDoc).expiresAt,
937
+ code: stored.code,
938
+ expiresAt: stored.expiresAt,
737
939
  };
738
940
  }
739
941
 
942
+ /**
943
+ * Opens a bearer session for a device the application pairs to a person
944
+ * who is signed in here, and answers its single-use hand-off code, which
945
+ * the device claims at `bearer/claim` with the verifier of `codeChallenge`,
946
+ * exactly like the code of a bearer login.
947
+ *
948
+ * `openerSessionId` names the session that confirmed the pairing, taken
949
+ * from the application's own record, never from the device. It must be a
950
+ * live provider login: not ended, not expired, its principal active, and
951
+ * with a provider login of its own. A session the application opened
952
+ * itself or one opened here does not qualify, so a paired device never
953
+ * pairs another one. Anything else throws
954
+ * `RelyingPartyError('invalid_handoff_opener')`.
955
+ *
956
+ * The new session acts as the opener's principal and has no provider login
957
+ * of its own: it ends by its expiry, `endSession`, `endSessionsOfPrincipal`,
958
+ * `bearer/logout` or the reuse of one of its credentials, never by an
959
+ * idp.global logout, and `AuthorityObserver` refuses it
960
+ * (`authority_login_required`). `maxAgeSeconds` is a whole number from 1 to
961
+ * `sessionMaxAgeSeconds`, the default, as for `openSession`. The code lives
962
+ * `BEARER_HANDOFF_MAX_AGE_SECONDS` at most and never past the session, and
963
+ * only its hash is stored; a code nobody claims ends its session. Nothing
964
+ * is stored for a refused opening.
965
+ */
966
+ public async openBearerHandoff(optionsArg: {
967
+ openerSessionId: string;
968
+ codeChallenge: string;
969
+ maxAgeSeconds?: number;
970
+ }): Promise<IRelyingPartyBearerHandoffOpening> {
971
+ const bearer = this.requireBearer();
972
+ if (!isPkceChallenge(optionsArg?.codeChallenge)) {
973
+ throw new RelyingPartyError('invalid_bearer_request');
974
+ }
975
+ const codeChallenge = optionsArg.codeChallenge;
976
+ const maxAgeSeconds = optionsArg.maxAgeSeconds ?? this.sessionMaxAgeSeconds;
977
+ if (!Number.isSafeInteger(maxAgeSeconds) || maxAgeSeconds < 1 || maxAgeSeconds > this.sessionMaxAgeSeconds) {
978
+ throw new RangeError(`openBearerHandoff maxAgeSeconds must be a whole number from 1 to ${this.sessionMaxAgeSeconds}`);
979
+ }
980
+ await this.sweepBearerState();
981
+ await this.sweepEndedSessions();
982
+ // read after the sweeps, so the opener is as live as it can be when the session is created
983
+ const openerSessionId = optionsArg.openerSessionId;
984
+ const opener = typeof openerSessionId === 'string' && openerSessionId.length > 0
985
+ ? await this.liveSession(await this.#models.Session.getInstance({ id: openerSessionId }))
986
+ : null;
987
+ if (!opener || typeof opener.document.idpSubject !== 'string' || opener.document.idpSubject.length === 0) {
988
+ throw new RelyingPartyError('invalid_handoff_opener');
989
+ }
990
+ // the token is never handed out: the session is reached by its code only, as a bearer login's
991
+ const session = this.newSessionDoc(randomToken(32), opener.document.userId, maxAgeSeconds);
992
+ await session.save();
993
+ let handoff: { code: string; expiresAt: number };
994
+ try {
995
+ handoff = await this.storeBearerHandoff(bearer, session, codeChallenge);
996
+ } catch (error) {
997
+ // the code never leaves, so the session is unreachable: it is revoked before the error goes on
998
+ try {
999
+ await this.discardUnopenedSession(session.id);
1000
+ } catch (discardError) {
1001
+ this.emit({ event: 'session_discard_failed', sessionId: session.id, message: errorMessage(discardError) });
1002
+ }
1003
+ throw error;
1004
+ }
1005
+ return { session: this.snapshot(session), code: handoff.code, expiresAt: handoff.expiresAt };
1006
+ }
1007
+
740
1008
  /** `abandonLogin` for a bearer login: the provider came back without a code. */
741
1009
  public async abandonBearerLogin(callbackUrlArg: URL): Promise<void> {
742
1010
  const state = singleStateOf(callbackUrlArg);
@@ -797,9 +1065,11 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
797
1065
  * access and refresh token answer, and the old access token stops working.
798
1066
  * Rotation is strict — a client serializes its refreshes — because a
799
1067
  * refresh token that comes back after it was spent ends the whole session.
800
- * The provider login is checked on the way: a login idp.global ended ends
801
- * the session here (`invalid_bearer_grant`), an unreachable provider fails
802
- * with `AuthorityTokenError('authority_unavailable')` and spends nothing.
1068
+ * The provider login of a bearer login is checked on the way: a login
1069
+ * idp.global ended ends the session here (`invalid_bearer_grant`), an
1070
+ * unreachable provider fails with `AuthorityTokenError('authority_unavailable')`
1071
+ * and spends nothing. A session `openBearerHandoff` opened has no provider
1072
+ * login (no `idpSubject` is stored) and rotates without the provider.
803
1073
  */
804
1074
  public async refreshBearerTokens(refreshTokenArg: unknown): Promise<IRelyingPartyBearerTokens> {
805
1075
  const bearer = this.requireBearer();
@@ -823,13 +1093,17 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
823
1093
  if (!live) {
824
1094
  throw new RelyingPartyError('invalid_bearer_grant');
825
1095
  }
826
- try {
827
- await this.liveProviderToken(live.document.id, live.document.userId);
828
- } catch (error) {
829
- if (error instanceof AuthorityTokenError && error.code === 'authority_login_required') {
830
- throw new RelyingPartyError('invalid_bearer_grant');
1096
+ // decided by what the session stores alone, never by a failure: a session
1097
+ // with a provider subject is a provider login and always asks the provider
1098
+ if (typeof live.document.idpSubject === 'string') {
1099
+ try {
1100
+ await this.liveProviderToken(live.document.id, live.document.userId);
1101
+ } catch (error) {
1102
+ if (error instanceof AuthorityTokenError && error.code === 'authority_login_required') {
1103
+ throw new RelyingPartyError('invalid_bearer_grant');
1104
+ }
1105
+ throw error;
831
1106
  }
832
- throw error;
833
1107
  }
834
1108
  // the next generation first: a rotation that fails half way leaves an
835
1109
  // unknown generation behind, never a session without a current one
@@ -910,8 +1184,22 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
910
1184
  * is unknown — is rotated first. The session has to belong to
911
1185
  * `expectedPrincipalIdArg`, so a credential is never evaluated with another
912
1186
  * person's tokens.
1187
+ *
1188
+ * With `rejectedAccessToken` (`IAuthorityTokenSource`) the call rotates the
1189
+ * session's token whatever is stored, and only a rotation that it starts
1190
+ * marks its answer `rotatedAfterRejection`: one that started before it, in
1191
+ * this process or another replica's, says nothing about the rejection, so
1192
+ * the call awaits it and throws `authority_unavailable`. A refused rotation
1193
+ * ends the session (`refresh_token_refused`) and throws
1194
+ * `authority_login_required`. A session without a refresh token has nothing
1195
+ * to rotate with: it throws `authority_unavailable`, or ends as
1196
+ * `access_token_unusable` when its token is dead.
913
1197
  */
914
- public async liveProviderToken(sessionIdArg: string, expectedPrincipalIdArg: string): Promise<IProviderToken> {
1198
+ public async liveProviderToken(
1199
+ sessionIdArg: string,
1200
+ expectedPrincipalIdArg: string,
1201
+ optionsArg: { rejectedAccessToken?: string } = {},
1202
+ ): Promise<IProviderToken> {
915
1203
  const session = await this.#models.Session.getInstance({ id: sessionIdArg });
916
1204
  const now = this.#now();
917
1205
  if (
@@ -926,35 +1214,60 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
926
1214
  const subject = session.idpSubject;
927
1215
  const expiresAt = typeof session.accessTokenExpiresAt === 'number' ? session.accessTokenExpiresAt : 0;
928
1216
  const accessToken = this.openSealed(session.sealedAccessToken);
1217
+ const forced = optionsArg.rejectedAccessToken !== undefined;
929
1218
  // an unknown expiry is rotated too: afterwards the lifetime is known
930
- const due = accessToken === undefined || expiresAt === 0 || expiresAt <= now + ACCESS_TOKEN_REFRESH_SKEW_MS;
1219
+ const due = forced
1220
+ || accessToken === undefined
1221
+ || expiresAt === 0
1222
+ || expiresAt <= now + ACCESS_TOKEN_REFRESH_SKEW_MS;
931
1223
  if (!due && accessToken !== undefined) {
932
1224
  return { accessToken, subject, expiresAt };
933
1225
  }
934
1226
  if (!session.sealedRefreshToken) {
935
1227
  if (accessToken !== undefined && (expiresAt === 0 || expiresAt > now)) {
936
1228
  // nothing to rotate with, and the token is not known to be dead
1229
+ if (forced) {
1230
+ throw new AuthorityTokenError('authority_unavailable');
1231
+ }
937
1232
  return { accessToken, subject, expiresAt };
938
1233
  }
939
1234
  await this.endProviderSession(sessionIdArg, 'access_token_unusable');
940
1235
  throw new AuthorityTokenError('authority_login_required');
941
1236
  }
942
- return this.refreshProviderToken(sessionIdArg, subject);
1237
+ return this.refreshProviderToken(sessionIdArg, subject, forced);
943
1238
  }
944
1239
 
945
- private async refreshProviderToken(sessionIdArg: string, subjectArg: string): Promise<IProviderToken> {
1240
+ /**
1241
+ * One rotation of the session in this process at a time. A forced call that
1242
+ * finds one running waits for it, so a refusal still ends it, and is
1243
+ * unavailable otherwise: that rotation started before the call.
1244
+ */
1245
+ private async refreshProviderToken(
1246
+ sessionIdArg: string,
1247
+ subjectArg: string,
1248
+ forcedArg: boolean,
1249
+ ): Promise<IProviderToken> {
946
1250
  const running = this.#refreshesInFlight.get(sessionIdArg);
947
1251
  if (running) {
948
- return running;
1252
+ const joined = await running;
1253
+ if (forcedArg) {
1254
+ throw new AuthorityTokenError('authority_unavailable');
1255
+ }
1256
+ return joined;
949
1257
  }
950
- const pending = this.runProviderRefresh(sessionIdArg, subjectArg).finally(() => {
1258
+ const pending = this.runProviderRefresh(sessionIdArg, subjectArg, forcedArg).finally(() => {
951
1259
  this.#refreshesInFlight.delete(sessionIdArg);
952
1260
  });
953
1261
  this.#refreshesInFlight.set(sessionIdArg, pending);
954
- return pending;
1262
+ // only the call that started the rotation is told so; others that join it are not
1263
+ return forcedArg ? { ...(await pending), rotatedAfterRejection: true } : pending;
955
1264
  }
956
1265
 
957
- private async runProviderRefresh(sessionIdArg: string, subjectArg: string): Promise<IProviderToken> {
1266
+ private async runProviderRefresh(
1267
+ sessionIdArg: string,
1268
+ subjectArg: string,
1269
+ forcedArg: boolean,
1270
+ ): Promise<IProviderToken> {
958
1271
  const provider = this.#provider;
959
1272
  if (!provider) {
960
1273
  throw new AuthorityTokenError('authority_unavailable');
@@ -973,15 +1286,21 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
973
1286
  { returnDocument: 'after' },
974
1287
  );
975
1288
  if (claimed.status !== 'matched' || !claimed.document) {
1289
+ if (forcedArg) {
1290
+ // nothing tells when the other replica's rotation started
1291
+ throw new AuthorityTokenError('authority_unavailable');
1292
+ }
976
1293
  return this.tokenAfterForeignRefresh(sessionIdArg, subjectArg);
977
1294
  }
978
1295
  const session = claimed.document as RelyingPartySessionDoc;
979
1296
  // the lock holder decides on the state it just read: a rotation that
980
1297
  // landed in between is used as it stands, because spending a superseded
981
- // refresh token would be reuse and would kill the family
1298
+ // refresh token would be reuse and would kill the family; a forced call
1299
+ // rotates the stored pair however long its token would last, since only a
1300
+ // rotation it starts comes after the rejection
982
1301
  const claimedExpiry = typeof session.accessTokenExpiresAt === 'number' ? session.accessTokenExpiresAt : 0;
983
1302
  const claimedToken = this.openSealed(session.sealedAccessToken);
984
- if (claimedToken !== undefined && claimedExpiry > this.#now() + ACCESS_TOKEN_REFRESH_SKEW_MS) {
1303
+ if (!forcedArg && claimedToken !== undefined && claimedExpiry > this.#now() + ACCESS_TOKEN_REFRESH_SKEW_MS) {
985
1304
  await this.#models.Session.atomicUpdate(
986
1305
  { id: sessionIdArg, revokedAt: { $exists: false }, refreshLockUntil: lockUntil },
987
1306
  { $set: { refreshLockUntil: 0 } },
@@ -1069,6 +1388,13 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1069
1388
  return this.#provider;
1070
1389
  }
1071
1390
 
1391
+ private requireCookieSessions(): ICookieSessionsMode {
1392
+ if (!this.#cookieSessions) {
1393
+ throw new RelyingPartyError('login_method_unavailable');
1394
+ }
1395
+ return this.#cookieSessions;
1396
+ }
1397
+
1072
1398
  private requireBearer(): IBearerMode {
1073
1399
  if (!this.#bearer) {
1074
1400
  throw new RelyingPartyError('login_method_unavailable');
@@ -1165,7 +1491,8 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1165
1491
  }
1166
1492
 
1167
1493
  private csrfTokenOf(tokenArg: string): string {
1168
- return deriveCsrfToken(tokenArg, this.#csrfKey, this.#csrfLabel);
1494
+ const { csrfKey, csrfLabel } = this.requireCookieSessions();
1495
+ return deriveCsrfToken(tokenArg, csrfKey, csrfLabel);
1169
1496
  }
1170
1497
 
1171
1498
  private opening(sessionArg: RelyingPartySessionDoc, tokenArg: string, maxAgeSecondsArg: number = this.sessionMaxAgeSeconds): IRelyingPartySessionOpening {
@@ -1252,7 +1579,8 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1252
1579
  }
1253
1580
  try {
1254
1581
  await beforeOpenedArg?.(session);
1255
- await this.#hooks.onProviderSessionOpened?.({ session: this.snapshot(session) });
1582
+ const delivery = this.bearerDeliveryOfAttempt(attemptArg);
1583
+ await this.#hooks.onProviderSessionOpened?.({ session: this.snapshot(session), ...(delivery ? { delivery } : {}) });
1256
1584
  } catch (error) {
1257
1585
  // the cookie (or the hand-off code) never leaves, so the session is
1258
1586
  // unreachable: it is revoked and its sealed tokens erased before the
@@ -1286,6 +1614,29 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1286
1614
  };
1287
1615
  }
1288
1616
 
1617
+ /**
1618
+ * Stores a new hand-off of a session, bound to the client's challenge, and
1619
+ * answers its code, which is stored only as a hash. The code lives
1620
+ * `BEARER_HANDOFF_MAX_AGE_SECONDS` at most and never past the session.
1621
+ */
1622
+ private async storeBearerHandoff(
1623
+ bearerArg: IBearerMode,
1624
+ sessionArg: RelyingPartySessionDoc,
1625
+ codeChallengeArg: string,
1626
+ ): Promise<{ code: string; expiresAt: number }> {
1627
+ const code = randomToken(32);
1628
+ const handoff = new bearerArg.models.BearerHandoff();
1629
+ handoff.id = 'rbh_' + randomToken(18);
1630
+ handoff.codeHash = sha256(code);
1631
+ handoff.sessionId = sessionArg.id;
1632
+ handoff.codeChallenge = codeChallengeArg;
1633
+ handoff.createdAt = this.#now();
1634
+ handoff.expiresAt = Math.min(handoff.createdAt + BEARER_HANDOFF_MAX_AGE_SECONDS * 1_000, sessionArg.expiresAt);
1635
+ handoff.consumedAt = null;
1636
+ await handoff.save();
1637
+ return { code, expiresAt: handoff.expiresAt };
1638
+ }
1639
+
1289
1640
  /** Stores the next token generation of a live session and answers its tokens; none of them is stored in the clear. */
1290
1641
  private async issueBearerTokens(bearerArg: IBearerMode, sessionArg: RelyingPartySessionDoc): Promise<IRelyingPartyBearerTokens> {
1291
1642
  const accessToken = randomToken(32);
@@ -1337,10 +1688,39 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1337
1688
 
1338
1689
  /** Revokes a session that was never handed out and erases its sealed tokens. */
1339
1690
  private async discardUnopenedSession(sessionIdArg: string): Promise<void> {
1340
- await this.#models.Session.atomicUpdate(
1691
+ await this.revokeSessionDocument(sessionIdArg);
1692
+ }
1693
+
1694
+ /**
1695
+ * Revokes a session not revoked yet and erases its sealed tokens; answers
1696
+ * the document as it was before, `null` when it was over already. Its
1697
+ * expiry comes forward to the revocation, never later, so the sweep finds
1698
+ * an ended session by its expiry alone and `revokedAt` keeps when and that
1699
+ * it was ended. Two conditional writes keep the earlier of the two times:
1700
+ * the first ends a session within its expiry, the second one past it. A
1701
+ * crash between them leaves an expired row unrevoked, which reads as over
1702
+ * and is swept; a row revoked before 19.4.0 kept its later expiry, which is
1703
+ * harmless because every reader checks `revokedAt` or reads live rows only,
1704
+ * and it ages out.
1705
+ */
1706
+ private async revokeSessionDocument(sessionIdArg: string): Promise<RelyingPartySessionDoc | null> {
1707
+ const now = this.#now();
1708
+ const withinExpiry = await this.#models.Session.atomicFindOneAndUpdate(
1709
+ { id: sessionIdArg, revokedAt: { $exists: false }, expiresAt: { $gt: now } },
1710
+ { $set: { revokedAt: now, expiresAt: now, ...REVOKED_TOKEN_FIELDS } },
1711
+ { returnDocument: 'before' },
1712
+ );
1713
+ if (withinExpiry.status === 'matched' && withinExpiry.document !== null) {
1714
+ return withinExpiry.document as RelyingPartySessionDoc;
1715
+ }
1716
+ const pastExpiry = await this.#models.Session.atomicFindOneAndUpdate(
1341
1717
  { id: sessionIdArg, revokedAt: { $exists: false } },
1342
- { $set: { revokedAt: this.#now(), ...REVOKED_TOKEN_FIELDS } },
1718
+ { $set: { revokedAt: now, ...REVOKED_TOKEN_FIELDS } },
1719
+ { returnDocument: 'before' },
1343
1720
  );
1721
+ return pastExpiry.status === 'matched' && pastExpiry.document !== null
1722
+ ? pastExpiry.document as RelyingPartySessionDoc
1723
+ : null;
1344
1724
  }
1345
1725
 
1346
1726
  /**
@@ -1348,11 +1728,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1348
1728
  * local revocation and the provider revocation: a throw or a rejection is
1349
1729
  * reported as `hook_failed` and the end runs on.
1350
1730
  */
1351
- private notifySessionEnded(eventArg: {
1352
- sessionId: string;
1353
- reason: TRelyingPartySessionEndReason;
1354
- revokedHere: boolean;
1355
- }): void {
1731
+ private notifySessionEnded(eventArg: Parameters<NonNullable<IRelyingPartyHooks['onSessionEnded']>>[0]): void {
1356
1732
  const hook = this.#hooks.onSessionEnded;
1357
1733
  if (!hook) {
1358
1734
  return;
@@ -1382,17 +1758,12 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1382
1758
  if (typeof sessionIdArg !== 'string' || sessionIdArg.length === 0) {
1383
1759
  return null;
1384
1760
  }
1385
- const revoked = await this.#models.Session.atomicFindOneAndUpdate(
1386
- { id: sessionIdArg, revokedAt: { $exists: false } },
1387
- { $set: { revokedAt: this.#now(), ...REVOKED_TOKEN_FIELDS } },
1388
- { returnDocument: 'before' },
1389
- );
1390
- const revokedHere = revoked.status === 'matched' && revoked.document !== null;
1391
- this.notifySessionEnded({ sessionId: sessionIdArg, reason: reasonArg, revokedHere });
1392
- if (!revokedHere) {
1761
+ const before = await this.revokeSessionDocument(sessionIdArg);
1762
+ if (!before) {
1763
+ this.notifySessionEnded({ sessionId: sessionIdArg, reason: reasonArg, revokedHere: false });
1393
1764
  return null;
1394
1765
  }
1395
- const before = revoked.document as RelyingPartySessionDoc;
1766
+ this.notifySessionEnded({ sessionId: sessionIdArg, reason: reasonArg, revokedHere: true, principalId: before.userId });
1396
1767
  return { sealedAccessToken: before.sealedAccessToken, sealedRefreshToken: before.sealedRefreshToken };
1397
1768
  }
1398
1769
 
@@ -1564,6 +1935,33 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1564
1935
  }
1565
1936
  }
1566
1937
 
1938
+ /**
1939
+ * Removes sessions an hour past their expiry, oldest first, one bounded
1940
+ * page per login start and per session the application opens. An ended
1941
+ * session's expiry is its revocation at the latest, so the expiry index
1942
+ * finds expired and revoked sessions alike. Each of them ended already: the
1943
+ * row goes without an end, a hook or a provider call. A row is deleted only
1944
+ * while its expiry is still past the cutoff, so a row whose expiry moved
1945
+ * after the page was read stays. A sweep that cannot run never blocks a
1946
+ * sign-in.
1947
+ */
1948
+ private async sweepEndedSessions(): Promise<void> {
1949
+ try {
1950
+ const cutoff = this.#now() - LOGIN_ATTEMPT_SWEEP_GRACE_MS;
1951
+ const page = await this.#models.Session.getPagedInstances({
1952
+ filter: { expiresAt: { $lt: cutoff } },
1953
+ sortField: 'expiresAt',
1954
+ sortDirection: 'asc',
1955
+ limit: LOGIN_ATTEMPT_SWEEP_BATCH,
1956
+ });
1957
+ for (const session of page.documents) {
1958
+ await this.#models.Session.atomicDelete({ id: session.id, expiresAt: { $lt: cutoff } });
1959
+ }
1960
+ } catch (error) {
1961
+ this.emit({ event: 'session_sweep_failed', message: errorMessage(error) });
1962
+ }
1963
+ }
1964
+
1567
1965
  /**
1568
1966
  * Reports one diagnostic. Every cleanup step reports through here, so a
1569
1967
  * throwing sink must not end it: the event and the sink's failure go to