@idp.global/sdk 19.4.0 → 19.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/changelog.md +60 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts_browser/classes.relyingpartybearerclient.d.ts +40 -3
  4. package/dist_ts_browser/classes.relyingpartybearerclient.js +67 -12
  5. package/dist_ts_relyingparty/classes.errors.d.ts +2 -0
  6. package/dist_ts_relyingparty/classes.errors.js +2 -1
  7. package/dist_ts_relyingparty/classes.idpoidcprovider.d.ts +69 -11
  8. package/dist_ts_relyingparty/classes.idpoidcprovider.js +127 -17
  9. package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +128 -18
  10. package/dist_ts_relyingparty/classes.relyingpartysessions.js +334 -69
  11. package/dist_ts_relyingparty/classes.rpmodels.d.ts +21 -4
  12. package/dist_ts_relyingparty/classes.rpmodels.js +35 -8
  13. package/dist_ts_relyingparty/contract.bearer.d.ts +37 -0
  14. package/dist_ts_relyingparty/contract.bearer.js +5 -1
  15. package/dist_ts_relyingparty/helpers.bearer.d.ts +2 -3
  16. package/dist_ts_relyingparty/helpers.bearer.js +4 -6
  17. package/dist_ts_relyingparty/helpers.cookies.d.ts +6 -0
  18. package/dist_ts_relyingparty/helpers.cookies.js +8 -1
  19. package/dist_ts_relyingparty/interfaces.d.ts +73 -3
  20. package/dist_ts_relyingparty/testing.js +8 -4
  21. package/package.json +3 -3
  22. package/readme.md +280 -34
  23. package/ts/00_commitinfo_data.ts +1 -1
  24. package/ts_browser/classes.relyingpartybearerclient.ts +98 -18
  25. package/ts_relyingparty/classes.errors.ts +3 -0
  26. package/ts_relyingparty/classes.idpoidcprovider.ts +183 -17
  27. package/ts_relyingparty/classes.relyingpartysessions.ts +393 -78
  28. package/ts_relyingparty/classes.rpmodels.ts +27 -4
  29. package/ts_relyingparty/contract.bearer.ts +46 -0
  30. package/ts_relyingparty/helpers.bearer.ts +3 -6
  31. package/ts_relyingparty/helpers.cookies.ts +13 -0
  32. package/ts_relyingparty/interfaces.ts +76 -7
  33. package/ts_relyingparty/testing.ts +7 -3
@@ -18,6 +18,7 @@ import type {
18
18
  IRelyingPartyBearerModels,
19
19
  IRelyingPartyModels,
20
20
  RelyingPartyBearerHandoffDoc,
21
+ RelyingPartyBearerTokenDoc,
21
22
  RelyingPartyLoginAttemptDoc,
22
23
  RelyingPartySessionDoc,
23
24
  } from './classes.rpmodels.js';
@@ -31,7 +32,7 @@ import {
31
32
  parseBearerAuthorization,
32
33
  verifyPkceChallenge,
33
34
  } from './helpers.bearer.js';
34
- import { parseCookies, serializeClearedCookie, serializeCookie } from './helpers.cookies.js';
35
+ import { parseCookies, serializeClearedCookie, serializeCookie, type ICookieOptions } from './helpers.cookies.js';
35
36
  import { deriveCsrfToken, randomToken, sha256, timingSafeEqualString } from './helpers.crypto.js';
36
37
  import { sanitizeReturnPath } from './helpers.returnpath.js';
37
38
  import type {
@@ -39,11 +40,13 @@ import type {
39
40
  IProviderToken,
40
41
  IRelyingPartyBearerDelivery,
41
42
  IRelyingPartyBearerHandoff,
43
+ IRelyingPartyBearerHandoffOpening,
42
44
  IRelyingPartyBearerLoginRequest,
43
45
  IRelyingPartyBearerLoginStart,
44
46
  IRelyingPartyBearerOptions,
45
47
  IRelyingPartyBearerReturnTargets,
46
48
  IRelyingPartyBearerTokens,
49
+ IRelyingPartyCookiePolicy,
47
50
  IRelyingPartyEvent,
48
51
  IRelyingPartyHooks,
49
52
  IRelyingPartyAdmission,
@@ -52,6 +55,7 @@ import type {
52
55
  IRelyingPartyLoginStart,
53
56
  IRelyingPartySession,
54
57
  IRelyingPartySessionOpening,
58
+ IRelyingPartySessionsErasure,
55
59
  IRelyingPartySessionsPage,
56
60
  IRelyingPartySessionsPageOptions,
57
61
  IResolvedRelyingPartySession,
@@ -85,6 +89,10 @@ const MAX_SESSIONS_CURSOR_LENGTH = 512;
85
89
  export const BEARER_HANDOFF_MAX_AGE_SECONDS = 60;
86
90
  /** How long a bearer access token lives unless `bearer.accessTokenMaxAgeSeconds` says otherwise. */
87
91
  export const RELYING_PARTY_DEFAULT_BEARER_ACCESS_TOKEN_MAX_AGE_SECONDS = 15 * 60;
92
+ /** How long a spent refresh token may come back as a lost answer unless `bearer.refreshReuseGraceSeconds` says otherwise. */
93
+ export const RELYING_PARTY_DEFAULT_REFRESH_REUSE_GRACE_SECONDS = 60;
94
+ /** The longest `bearer.refreshReuseGraceSeconds`: a spent token that comes back later is always reuse. */
95
+ export const RELYING_PARTY_MAX_REFRESH_REUSE_GRACE_SECONDS = 300;
88
96
  /** The codes and tokens of bearer mode are 43 characters; anything far longer is none of them. */
89
97
  const MAX_BEARER_TOKEN_LENGTH = 256;
90
98
  /**
@@ -133,6 +141,14 @@ export interface IRelyingPartySessionsOptions<TPrincipal extends object | undefi
133
141
  session?: string;
134
142
  intent?: string;
135
143
  };
144
+ /**
145
+ * How the session and intent cookies go across sites: `{ sameSite: 'Lax' }`
146
+ * by default. A page framed on another site needs `sameSite: 'None'` (an
147
+ * https `publicOrigin` only), with `partitioned: true` where the browser
148
+ * keeps third-party cookies per top-level site. The constructor refuses
149
+ * every other combination.
150
+ */
151
+ cookiePolicy?: IRelyingPartyCookiePolicy;
136
152
  /** the prefix of the login routes; a login never returns into it */
137
153
  loginPathPrefix?: string;
138
154
  /**
@@ -150,21 +166,23 @@ export interface IRelyingPartySessionsOptions<TPrincipal extends object | undefi
150
166
  * sign in through bearer hand-off and nothing else. The relying party never
151
167
  * reads or sets a cookie — not even the clearing intent cookie of a callback
152
168
  * that is no bearer login — so it has no CSRF token and takes no CSRF key:
153
- * `csrfKey`, `csrfLabel` and `cookieNames` are refused, not ignored. The
169
+ * `csrfKey`, `csrfLabel`, `cookieNames` and `cookiePolicy` are refused, not
170
+ * ignored. The
154
171
  * cookie steps (`openSession`, `startLogin`, `completeLogin`, `abandonLogin`,
155
172
  * `finalizeLogin`, `clearSessionCookie`, `clearIntentCookie`) throw
156
173
  * `RelyingPartyError('login_method_unavailable')` and `resolveCookieSession`
157
174
  * answers `null`.
158
175
  */
159
176
  export interface IRelyingPartyBearerOnlySessionsOptions<TPrincipal extends object | undefined = undefined>
160
- extends Omit<IRelyingPartySessionsOptions<TPrincipal>, 'provider' | 'csrfKey' | 'csrfLabel' | 'cookieNames' | 'cookieSessions' | 'bearer'> {
177
+ extends Omit<IRelyingPartySessionsOptions<TPrincipal>, 'provider' | 'csrfKey' | 'csrfLabel' | 'cookieNames' | 'cookiePolicy' | 'cookieSessions' | 'bearer'> {
161
178
  cookieSessions: false;
162
- /** every bearer session is a provider login */
179
+ /** every bearer session comes from a live provider login, directly or through a hand-off opened from one */
163
180
  provider: IOidcProvider;
164
181
  bearer: IRelyingPartyBearerOptions;
165
182
  csrfKey?: never;
166
183
  csrfLabel?: never;
167
184
  cookieNames?: never;
185
+ cookiePolicy?: never;
168
186
  }
169
187
 
170
188
  type TSealedTokens = Pick<RelyingPartySessionDoc, 'sealedAccessToken' | 'sealedRefreshToken'>;
@@ -179,6 +197,8 @@ interface ICookieSessionsMode {
179
197
  interface IBearerMode {
180
198
  returnTargets: Readonly<Required<IRelyingPartyBearerReturnTargets>>;
181
199
  accessTokenMaxAgeSeconds: number;
200
+ /** `0`: a spent refresh token that comes back is always reuse */
201
+ refreshReuseGraceMs: number;
182
202
  models: IRelyingPartyBearerModels;
183
203
  }
184
204
 
@@ -190,7 +210,7 @@ const bearerModeOf = (
190
210
  throw new Error('bearer must be an object with returnTargets');
191
211
  }
192
212
  if (!optionsArg.hasProvider) {
193
- throw new Error('bearer needs a provider: every bearer session is a provider login');
213
+ throw new Error('bearer needs a provider: every bearer session comes from a provider login');
194
214
  }
195
215
  const { BearerHandoff, BearerToken } = optionsArg.models;
196
216
  if (!BearerHandoff || !BearerToken) {
@@ -205,9 +225,18 @@ const bearerModeOf = (
205
225
  ) {
206
226
  throw new Error(`bearer.accessTokenMaxAgeSeconds must be a whole number from 1 to ${optionsArg.sessionMaxAgeSeconds}`);
207
227
  }
228
+ const refreshReuseGraceSeconds = bearerArg.refreshReuseGraceSeconds ?? RELYING_PARTY_DEFAULT_REFRESH_REUSE_GRACE_SECONDS;
229
+ if (
230
+ !Number.isSafeInteger(refreshReuseGraceSeconds)
231
+ || refreshReuseGraceSeconds < 0
232
+ || refreshReuseGraceSeconds > RELYING_PARTY_MAX_REFRESH_REUSE_GRACE_SECONDS
233
+ ) {
234
+ throw new Error(`bearer.refreshReuseGraceSeconds must be a whole number from 0 to ${RELYING_PARTY_MAX_REFRESH_REUSE_GRACE_SECONDS}`);
235
+ }
208
236
  return {
209
237
  returnTargets: normalizeBearerReturnTargets(bearerArg.returnTargets, optionsArg.publicOrigin, optionsArg.loginPathPrefix),
210
238
  accessTokenMaxAgeSeconds,
239
+ refreshReuseGraceMs: refreshReuseGraceSeconds * 1_000,
211
240
  models: { BearerHandoff, BearerToken },
212
241
  };
213
242
  };
@@ -284,6 +313,34 @@ const cookieNameFor = (
284
313
  return configuredArg;
285
314
  };
286
315
 
316
+ /**
317
+ * The cookie policy as the constructor checked it. Every combination it does
318
+ * not resolve is refused here, never when a cookie is written.
319
+ */
320
+ const cookiePolicyOf = (policyArg: unknown, secureArg: boolean): Readonly<Required<IRelyingPartyCookiePolicy>> => {
321
+ if (policyArg === undefined) {
322
+ return { sameSite: 'Lax', partitioned: false };
323
+ }
324
+ if (!policyArg || typeof policyArg !== 'object' || Array.isArray(policyArg)) {
325
+ throw new Error('cookiePolicy must be an object');
326
+ }
327
+ const sameSite: unknown = Reflect.get(policyArg, 'sameSite') ?? 'Lax';
328
+ const partitioned: unknown = Reflect.get(policyArg, 'partitioned') ?? false;
329
+ if (sameSite !== 'Lax' && sameSite !== 'None') {
330
+ throw new Error("cookiePolicy.sameSite must be 'Lax' or 'None'");
331
+ }
332
+ if (typeof partitioned !== 'boolean') {
333
+ throw new Error('cookiePolicy.partitioned must be true or false');
334
+ }
335
+ if (sameSite === 'None' && !secureArg) {
336
+ throw new Error("cookiePolicy.sameSite 'None' needs an https publicOrigin: browsers drop a SameSite=None cookie that is not set Secure");
337
+ }
338
+ if (partitioned && sameSite !== 'None') {
339
+ throw new Error("cookiePolicy.partitioned needs sameSite 'None': only a cross-site cookie is partitioned");
340
+ }
341
+ return { sameSite, partitioned };
342
+ };
343
+
287
344
  /**
288
345
  * The relying-party session layer: provider logins through the authorization
289
346
  * code flow with PKCE, HttpOnly cookie sessions with a session-bound CSRF
@@ -297,10 +354,10 @@ const cookieNameFor = (
297
354
  * ended sessions are swept one bounded batch at a time when a login starts or
298
355
  * the application opens a session, and the only timer — the revocation
299
356
  * budget — is unreferenced and cleared on every path. Every session comes
300
- * from a login start or from `openSession`, and each of those sweeps a batch
301
- * of at least one first, so removal keeps pace with creation and the ended
302
- * sessions kept stay bounded. Nothing here does I/O while it is constructed,
303
- * so it is built before the database connects.
357
+ * from a login start, `openSession` or `openBearerHandoff`, and each of those
358
+ * sweeps a batch of at least one first, so removal keeps pace with creation
359
+ * and the ended sessions kept stay bounded. Nothing here does I/O while it is
360
+ * constructed, so it is built before the database connects.
304
361
  *
305
362
  * `TPrincipal` is what a `principals.resolve` answers; with `isActive`
306
363
  * principals it stays `undefined`.
@@ -310,6 +367,8 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
310
367
  public readonly secureCookies: boolean;
311
368
  public readonly sessionCookieName: string;
312
369
  public readonly intentCookieName: string;
370
+ /** how the session and intent cookies go across sites, as the constructor resolved `cookiePolicy` */
371
+ public readonly cookiePolicy: Readonly<Required<IRelyingPartyCookiePolicy>>;
313
372
  public readonly loginPathPrefix: string;
314
373
  public readonly sessionMaxAgeSeconds: number;
315
374
  readonly #models: IRelyingPartyModels;
@@ -339,7 +398,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
339
398
  throw new Error('csrfKey must be 32 bytes');
340
399
  }
341
400
  } else {
342
- for (const name of ['csrfKey', 'csrfLabel', 'cookieNames'] as const) {
401
+ for (const name of ['csrfKey', 'csrfLabel', 'cookieNames', 'cookiePolicy'] as const) {
343
402
  if (optionsArg[name] !== undefined) {
344
403
  throw new Error(`${name} has no use with cookieSessions: false, which reads and sets no cookie`);
345
404
  }
@@ -367,6 +426,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
367
426
  this.secureCookies,
368
427
  'intent',
369
428
  );
429
+ this.cookiePolicy = cookiePolicyOf(optionsArg.cookiePolicy, this.secureCookies);
370
430
  this.loginPathPrefix = optionsArg.loginPathPrefix ?? RELYING_PARTY_DEFAULT_LOGIN_PATH_PREFIX;
371
431
  if (!this.loginPathPrefix.startsWith('/') || !this.loginPathPrefix.endsWith('/')) {
372
432
  throw new Error('loginPathPrefix must start and end with a slash');
@@ -559,6 +619,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
559
619
  /**
560
620
  * Ends every live session of a principal but `keepSessionId` (a password
561
621
  * change keeps the session that asked for it) and answers how many ended.
622
+ * A session that expired is over already and is neither ended nor counted.
562
623
  */
563
624
  public async endSessionsOfPrincipal(
564
625
  principalIdArg: string,
@@ -568,6 +629,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
568
629
  const sessions = await this.#models.Session.getInstances({
569
630
  userId: principalIdArg,
570
631
  revokedAt: { $exists: false },
632
+ expiresAt: { $gt: this.#now() },
571
633
  });
572
634
  for (const session of sessions) {
573
635
  if (session.id === optionsArg.keepSessionId) {
@@ -580,6 +642,54 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
580
642
  return ended;
581
643
  }
582
644
 
645
+ /**
646
+ * Erases every session of a principal, live or ended, and every row that
647
+ * belongs to one, without waiting for a sweep: what an application runs
648
+ * when it deletes an account, after its own erasers and before its user
649
+ * record.
650
+ *
651
+ * First each session not revoked yet, an expired one too, ends as
652
+ * `endSession` ends it: its tokens stop working at once, `onSessionEnded`
653
+ * names the principal, and its provider tokens go back to idp.global,
654
+ * which a deleted row could no longer do. Then each session's rows go: in
655
+ * bearer mode its hand-offs, claimed or not, and its token generations;
656
+ * the login attempt a provider session came out of; and the session row
657
+ * last, because it is the only link from the principal to the others, so
658
+ * an erase that fails half way leaves a retry everything it needs. A
659
+ * session opened or a token issued while the erase runs is not covered;
660
+ * it works only while the principal is active.
661
+ */
662
+ public async eraseSessionsOfPrincipal(principalIdArg: string): Promise<IRelyingPartySessionsErasure> {
663
+ if (typeof principalIdArg !== 'string' || principalIdArg === '') {
664
+ throw new Error('eraseSessionsOfPrincipal needs a principal id');
665
+ }
666
+ const erasure: IRelyingPartySessionsErasure = {
667
+ endedSessions: 0,
668
+ deletedSessions: 0,
669
+ deletedLoginAttempts: 0,
670
+ deletedBearerHandoffs: 0,
671
+ deletedBearerTokens: 0,
672
+ };
673
+ const sessions = await this.#models.Session.getInstances({ userId: principalIdArg });
674
+ for (const session of sessions) {
675
+ if (session.revokedAt === undefined && (await this.endSession(session.id))) {
676
+ erasure.endedSessions += 1;
677
+ }
678
+ }
679
+ const bearerModels = this.#bearer?.models;
680
+ for (const session of sessions) {
681
+ if (bearerModels) {
682
+ erasure.deletedBearerHandoffs += (await bearerModels.BearerHandoff.atomicDeleteMany({ sessionId: session.id })).deletedCount;
683
+ erasure.deletedBearerTokens += (await bearerModels.BearerToken.atomicDeleteMany({ sessionId: session.id })).deletedCount;
684
+ }
685
+ if (session.loginAttemptId) {
686
+ erasure.deletedLoginAttempts += (await this.#models.LoginAttempt.atomicDelete({ id: session.loginAttemptId })).deletedCount;
687
+ }
688
+ erasure.deletedSessions += (await this.#models.Session.atomicDelete({ id: session.id })).deletedCount;
689
+ }
690
+ return erasure;
691
+ }
692
+
583
693
  /**
584
694
  * One page of a principal's live sessions, newest first: the ones neither
585
695
  * ended nor expired, and none at all while the principal is not active —
@@ -620,20 +730,12 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
620
730
 
621
731
  public clearSessionCookie(): string {
622
732
  this.requireCookieSessions();
623
- return serializeClearedCookie(this.sessionCookieName, {
624
- httpOnly: true,
625
- secure: this.secureCookies,
626
- sameSite: 'Lax',
627
- });
733
+ return serializeClearedCookie(this.sessionCookieName, this.cookieAttributes());
628
734
  }
629
735
 
630
736
  public clearIntentCookie(): string {
631
737
  this.requireCookieSessions();
632
- return serializeClearedCookie(this.intentCookieName, {
633
- httpOnly: true,
634
- secure: this.secureCookies,
635
- sameSite: 'Lax',
636
- });
738
+ return serializeClearedCookie(this.intentCookieName, this.cookieAttributes());
637
739
  }
638
740
 
639
741
  // ==========================================================================
@@ -667,9 +769,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
667
769
  return {
668
770
  authorizationUrl,
669
771
  intentCookie: serializeCookie(this.intentCookieName, bindingToken, {
670
- httpOnly: true,
671
- secure: this.secureCookies,
672
- sameSite: 'Lax',
772
+ ...this.cookieAttributes(),
673
773
  maxAgeSeconds: LOGIN_ATTEMPT_MAX_AGE_SECONDS,
674
774
  }),
675
775
  };
@@ -876,28 +976,85 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
876
976
  if (!isPkceChallenge(codeChallenge)) {
877
977
  throw new RelyingPartyError('invalid_or_replayed_login');
878
978
  }
879
- const code = randomToken(32);
880
- let handoff: RelyingPartyBearerHandoffDoc | undefined;
979
+ let handoff: { code: string; expiresAt: number } | undefined;
881
980
  const opened = await this.openProviderLogin(provider, callbackUrlArg, state, attempt, async (sessionArg) => {
882
- const created = new bearer.models.BearerHandoff();
883
- created.id = 'rbh_' + randomToken(18);
884
- created.codeHash = sha256(code);
885
- created.sessionId = sessionArg.id;
886
- created.codeChallenge = codeChallenge;
887
- created.createdAt = this.#now();
888
- created.expiresAt = Math.min(created.createdAt + BEARER_HANDOFF_MAX_AGE_SECONDS * 1_000, sessionArg.expiresAt);
889
- created.consumedAt = null;
890
- await created.save();
891
- handoff = created;
981
+ handoff = await this.storeBearerHandoff(bearer, sessionArg, codeChallenge);
892
982
  });
983
+ const stored = handoff as { code: string; expiresAt: number };
893
984
  return {
894
985
  session: this.snapshot(opened.document),
895
986
  delivery,
896
- code,
897
- expiresAt: (handoff as RelyingPartyBearerHandoffDoc).expiresAt,
987
+ code: stored.code,
988
+ expiresAt: stored.expiresAt,
898
989
  };
899
990
  }
900
991
 
992
+ /**
993
+ * Opens a bearer session for a device the application pairs to a person
994
+ * who is signed in here, and answers its single-use hand-off code, which
995
+ * the device claims at `bearer/claim` with the verifier of `codeChallenge`,
996
+ * exactly like the code of a bearer login.
997
+ *
998
+ * `openerSessionId` names the session that confirmed the pairing, taken
999
+ * from the application's own record, never from the device. It must be a
1000
+ * live provider login: not ended, not expired, its principal active, and
1001
+ * with a provider login of its own. A session the application opened
1002
+ * itself or one opened here does not qualify, so a paired device never
1003
+ * pairs another one. Anything else throws
1004
+ * `RelyingPartyError('invalid_handoff_opener')`.
1005
+ *
1006
+ * The new session acts as the opener's principal and has no provider login
1007
+ * of its own: it ends by its expiry, `endSession`, `endSessionsOfPrincipal`,
1008
+ * `bearer/logout` or the reuse of one of its credentials, never by an
1009
+ * idp.global logout, and `AuthorityObserver` refuses it
1010
+ * (`authority_login_required`). `maxAgeSeconds` is a whole number from 1 to
1011
+ * `sessionMaxAgeSeconds`, the default, as for `openSession`. The code lives
1012
+ * `BEARER_HANDOFF_MAX_AGE_SECONDS` at most and never past the session, and
1013
+ * only its hash is stored; a code nobody claims ends its session. Nothing
1014
+ * is stored for a refused opening.
1015
+ */
1016
+ public async openBearerHandoff(optionsArg: {
1017
+ openerSessionId: string;
1018
+ codeChallenge: string;
1019
+ maxAgeSeconds?: number;
1020
+ }): Promise<IRelyingPartyBearerHandoffOpening> {
1021
+ const bearer = this.requireBearer();
1022
+ if (!isPkceChallenge(optionsArg?.codeChallenge)) {
1023
+ throw new RelyingPartyError('invalid_bearer_request');
1024
+ }
1025
+ const codeChallenge = optionsArg.codeChallenge;
1026
+ const maxAgeSeconds = optionsArg.maxAgeSeconds ?? this.sessionMaxAgeSeconds;
1027
+ if (!Number.isSafeInteger(maxAgeSeconds) || maxAgeSeconds < 1 || maxAgeSeconds > this.sessionMaxAgeSeconds) {
1028
+ throw new RangeError(`openBearerHandoff maxAgeSeconds must be a whole number from 1 to ${this.sessionMaxAgeSeconds}`);
1029
+ }
1030
+ await this.sweepBearerState();
1031
+ await this.sweepEndedSessions();
1032
+ // read after the sweeps, so the opener is as live as it can be when the session is created
1033
+ const openerSessionId = optionsArg.openerSessionId;
1034
+ const opener = typeof openerSessionId === 'string' && openerSessionId.length > 0
1035
+ ? await this.liveSession(await this.#models.Session.getInstance({ id: openerSessionId }))
1036
+ : null;
1037
+ if (!opener || typeof opener.document.idpSubject !== 'string' || opener.document.idpSubject.length === 0) {
1038
+ throw new RelyingPartyError('invalid_handoff_opener');
1039
+ }
1040
+ // the token is never handed out: the session is reached by its code only, as a bearer login's
1041
+ const session = this.newSessionDoc(randomToken(32), opener.document.userId, maxAgeSeconds);
1042
+ await session.save();
1043
+ let handoff: { code: string; expiresAt: number };
1044
+ try {
1045
+ handoff = await this.storeBearerHandoff(bearer, session, codeChallenge);
1046
+ } catch (error) {
1047
+ // the code never leaves, so the session is unreachable: it is revoked before the error goes on
1048
+ try {
1049
+ await this.discardUnopenedSession(session.id);
1050
+ } catch (discardError) {
1051
+ this.emit({ event: 'session_discard_failed', sessionId: session.id, message: errorMessage(discardError) });
1052
+ }
1053
+ throw error;
1054
+ }
1055
+ return { session: this.snapshot(session), code: handoff.code, expiresAt: handoff.expiresAt };
1056
+ }
1057
+
901
1058
  /** `abandonLogin` for a bearer login: the provider came back without a code. */
902
1059
  public async abandonBearerLogin(callbackUrlArg: URL): Promise<void> {
903
1060
  const state = singleStateOf(callbackUrlArg);
@@ -950,17 +1107,26 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
950
1107
  if (!live) {
951
1108
  throw new RelyingPartyError('invalid_bearer_handoff');
952
1109
  }
953
- return this.issueBearerTokens(bearer, live.document);
1110
+ return (await this.issueBearerTokens(bearer, live.document)).tokens;
954
1111
  }
955
1112
 
956
1113
  /**
957
1114
  * Rotates a bearer session's tokens: the refresh token is spent, a new
958
1115
  * access and refresh token answer, and the old access token stops working.
959
- * Rotation is strict — a client serializes its refreshes — because a
960
- * refresh token that comes back after it was spent ends the whole session.
961
- * The provider login is checked on the way: a login idp.global ended ends
962
- * the session here (`invalid_bearer_grant`), an unreachable provider fails
963
- * with `AuthorityTokenError('authority_unavailable')` and spends nothing.
1116
+ * A client serializes its refreshes, because a refresh token that comes
1117
+ * back after it was spent ends the whole session, with one exception for
1118
+ * an answer that never reached the client: within
1119
+ * `bearer.refreshReuseGraceSeconds` of its rotation (60 by default), a
1120
+ * spent refresh token whose successor was never presented — neither its
1121
+ * access token nor its refresh token — revokes that successor and answers a
1122
+ * new pair (`bearer_refresh_reissued`). Only hashes are stored, so the lost
1123
+ * pair is replaced, never repeated. Once the successor was presented, after
1124
+ * the window, or for a second retry that loses the race to the first, it is
1125
+ * reuse. The provider login of a bearer login is checked on the way: a
1126
+ * login idp.global ended ends the session here (`invalid_bearer_grant`), an
1127
+ * unreachable provider fails with `AuthorityTokenError('authority_unavailable')`
1128
+ * and spends nothing. A session `openBearerHandoff` opened has no provider
1129
+ * login (no `idpSubject` is stored) and rotates without the provider.
964
1130
  */
965
1131
  public async refreshBearerTokens(refreshTokenArg: unknown): Promise<IRelyingPartyBearerTokens> {
966
1132
  const bearer = this.requireBearer();
@@ -973,39 +1139,135 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
973
1139
  if (!current) {
974
1140
  throw new RelyingPartyError('invalid_bearer_grant');
975
1141
  }
976
- if (current.rotatedAt !== null) {
1142
+ const lostSuccessor = current.rotatedAt !== null ? await this.lostSuccessorOf(bearer, current) : null;
1143
+ if (current.rotatedAt !== null && !lostSuccessor) {
977
1144
  await this.bearerCredentialReused(current.sessionId, 'refresh_token_reused');
978
1145
  throw new RelyingPartyError('invalid_bearer_grant');
979
1146
  }
980
1147
  if (current.expiresAt <= this.#now()) {
981
1148
  throw new RelyingPartyError('invalid_bearer_grant');
982
1149
  }
1150
+ // presented: from now on its predecessor's refresh token is no lost answer;
1151
+ // one that a lost answer of its predecessor revoked just now is reuse
1152
+ if (!lostSuccessor && !(await this.markBearerGenerationPresented(bearer, current))) {
1153
+ await this.bearerCredentialReused(current.sessionId, 'refresh_token_reused');
1154
+ throw new RelyingPartyError('invalid_bearer_grant');
1155
+ }
983
1156
  const live = await this.liveSession(await this.#models.Session.getInstance({ id: current.sessionId }));
984
1157
  if (!live) {
985
1158
  throw new RelyingPartyError('invalid_bearer_grant');
986
1159
  }
987
- try {
988
- await this.liveProviderToken(live.document.id, live.document.userId);
989
- } catch (error) {
990
- if (error instanceof AuthorityTokenError && error.code === 'authority_login_required') {
991
- throw new RelyingPartyError('invalid_bearer_grant');
1160
+ // decided by what the session stores alone, never by a failure: a session
1161
+ // with a provider subject is a provider login and always asks the provider
1162
+ if (typeof live.document.idpSubject === 'string') {
1163
+ try {
1164
+ await this.liveProviderToken(live.document.id, live.document.userId);
1165
+ } catch (error) {
1166
+ if (error instanceof AuthorityTokenError && error.code === 'authority_login_required') {
1167
+ throw new RelyingPartyError('invalid_bearer_grant');
1168
+ }
1169
+ throw error;
992
1170
  }
993
- throw error;
1171
+ }
1172
+ if (lostSuccessor) {
1173
+ return this.reissueLostRotation(bearer, current, lostSuccessor, live.document);
994
1174
  }
995
1175
  // the next generation first: a rotation that fails half way leaves an
996
1176
  // unknown generation behind, never a session without a current one
997
1177
  const issued = await this.issueBearerTokens(bearer, live.document);
998
1178
  const rotated = await bearer.models.BearerToken.atomicUpdate(
999
1179
  { refreshTokenHash, rotatedAt: null },
1000
- { $set: { rotatedAt: this.#now() } },
1180
+ { $set: { rotatedAt: this.#now(), successorId: issued.generationId } },
1001
1181
  );
1002
1182
  if (rotated.matchedCount !== 1) {
1003
- // a concurrent refresh spent it first; the generation just issued never
1004
- // left this call and dies with the session
1183
+ // a concurrent refresh spent it first, or a lost answer of its
1184
+ // predecessor revoked it; the generation just issued never left this
1185
+ // call and dies with the session
1005
1186
  await this.bearerCredentialReused(current.sessionId, 'refresh_token_reused');
1006
1187
  throw new RelyingPartyError('invalid_bearer_grant');
1007
1188
  }
1008
- return issued;
1189
+ return issued.tokens;
1190
+ }
1191
+
1192
+ /**
1193
+ * The successor of a spent generation when its answer may have been lost:
1194
+ * the rotation is at most `refreshReuseGraceMs` old, and the successor is
1195
+ * still current and was never presented. `null` otherwise, which is reuse.
1196
+ */
1197
+ private async lostSuccessorOf(
1198
+ bearerArg: IBearerMode,
1199
+ spentArg: RelyingPartyBearerTokenDoc,
1200
+ ): Promise<RelyingPartyBearerTokenDoc | null> {
1201
+ if (
1202
+ bearerArg.refreshReuseGraceMs === 0
1203
+ || spentArg.rotatedAt === null
1204
+ || typeof spentArg.successorId !== 'string'
1205
+ || this.#now() - spentArg.rotatedAt > bearerArg.refreshReuseGraceMs
1206
+ ) {
1207
+ return null;
1208
+ }
1209
+ const successor = await bearerArg.models.BearerToken.getInstance({ id: spentArg.successorId });
1210
+ if (!successor || successor.sessionId !== spentArg.sessionId || successor.rotatedAt !== null || successor.firstUsedAt !== null) {
1211
+ return null;
1212
+ }
1213
+ return successor;
1214
+ }
1215
+
1216
+ /**
1217
+ * Issues a pair again for a spent generation whose answer was lost. The
1218
+ * successor that answer carried is revoked first, by one conditional write
1219
+ * that needs it current and unpresented: of two retries at once exactly one
1220
+ * takes it, and the other is reuse, as is a successor presented in the
1221
+ * meantime. Then the new generation is stored and the spent one names it,
1222
+ * so a retry after this answer is lost too finds it within the same window
1223
+ * (counted from the original rotation). A failure after the revocation
1224
+ * leaves the session without a current generation; its retry is reuse.
1225
+ */
1226
+ private async reissueLostRotation(
1227
+ bearerArg: IBearerMode,
1228
+ spentArg: RelyingPartyBearerTokenDoc,
1229
+ successorArg: RelyingPartyBearerTokenDoc,
1230
+ sessionArg: RelyingPartySessionDoc,
1231
+ ): Promise<IRelyingPartyBearerTokens> {
1232
+ const revoked = await bearerArg.models.BearerToken.atomicUpdate(
1233
+ { id: successorArg.id, rotatedAt: null, firstUsedAt: null },
1234
+ { $set: { rotatedAt: this.#now() } },
1235
+ );
1236
+ if (revoked.matchedCount !== 1) {
1237
+ await this.bearerCredentialReused(spentArg.sessionId, 'refresh_token_reused');
1238
+ throw new RelyingPartyError('invalid_bearer_grant');
1239
+ }
1240
+ const issued = await this.issueBearerTokens(bearerArg, sessionArg);
1241
+ // only the call that revoked the successor gets here, so the link is its own to move
1242
+ await bearerArg.models.BearerToken.atomicUpdate(
1243
+ { id: spentArg.id, successorId: successorArg.id },
1244
+ { $set: { successorId: issued.generationId } },
1245
+ );
1246
+ this.emit({ event: 'bearer_refresh_reissued', sessionId: spentArg.sessionId });
1247
+ return issued.tokens;
1248
+ }
1249
+
1250
+ /**
1251
+ * Records the first presentation of a current generation's access or refresh
1252
+ * token, once. Answers whether the generation is still current: `false`
1253
+ * when a lost answer of its predecessor revoked it just now.
1254
+ */
1255
+ private async markBearerGenerationPresented(bearerArg: IBearerMode, generationArg: RelyingPartyBearerTokenDoc): Promise<boolean> {
1256
+ if (generationArg.firstUsedAt !== null) {
1257
+ return true;
1258
+ }
1259
+ const now = this.#now();
1260
+ const marked = await bearerArg.models.BearerToken.atomicUpdate(
1261
+ { id: generationArg.id, rotatedAt: null, firstUsedAt: null },
1262
+ { $set: { firstUsedAt: now } },
1263
+ );
1264
+ if (marked.matchedCount === 1) {
1265
+ generationArg.firstUsedAt = now;
1266
+ return true;
1267
+ }
1268
+ // another request marked it first, or it was revoked in between
1269
+ const stored = await bearerArg.models.BearerToken.getInstance({ id: generationArg.id });
1270
+ return stored !== null && stored.rotatedAt === null;
1009
1271
  }
1010
1272
 
1011
1273
  /**
@@ -1023,7 +1285,11 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1023
1285
  return null;
1024
1286
  }
1025
1287
  const live = await this.liveSession(await this.#models.Session.getInstance({ id: generation.sessionId }));
1026
- return live ? { session: this.snapshot(live.document), principal: live.principal } : null;
1288
+ // presented: its predecessor's refresh token is no lost answer any more
1289
+ if (!live || !(await this.markBearerGenerationPresented(this.#bearer, generation))) {
1290
+ return null;
1291
+ }
1292
+ return { session: this.snapshot(live.document), principal: live.principal };
1027
1293
  }
1028
1294
 
1029
1295
  /** `resolveBearerAccessToken` for an `Authorization: Bearer <token>` header. */
@@ -1036,7 +1302,9 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1036
1302
  /**
1037
1303
  * Ends the session of a bearer token — a live access token or the current
1038
1304
  * refresh token — like `endSession`. A refresh token that was already spent
1039
- * is reuse and ends the session as such. Answers whether this call ended it.
1305
+ * is reuse and ends the session as such, unless `refreshBearerTokens` would
1306
+ * take it as a lost answer (`lostSuccessorOf`): then it is a logout like the
1307
+ * current one. Answers whether this call ended it.
1040
1308
  */
1041
1309
  public async endBearerSession(tokenArg: unknown): Promise<boolean> {
1042
1310
  const token = this.#bearer ? bearerCredentialOf(tokenArg) : null;
@@ -1055,7 +1323,8 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1055
1323
  if (!byRefreshToken) {
1056
1324
  return false;
1057
1325
  }
1058
- if (byRefreshToken.rotatedAt !== null) {
1326
+ // the same predicate as the refresh: a sign-out after a lost answer is no theft
1327
+ if (byRefreshToken.rotatedAt !== null && !(await this.lostSuccessorOf(this.#bearer, byRefreshToken))) {
1059
1328
  return this.bearerCredentialReused(byRefreshToken.sessionId, 'refresh_token_reused');
1060
1329
  }
1061
1330
  return this.endSession(byRefreshToken.sessionId, 'logout');
@@ -1377,6 +1646,16 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1377
1646
  return session;
1378
1647
  }
1379
1648
 
1649
+ /** The attributes every session and intent cookie is set and cleared with: one policy for all of them. */
1650
+ private cookieAttributes(): Omit<ICookieOptions, 'maxAgeSeconds'> {
1651
+ return {
1652
+ httpOnly: true,
1653
+ secure: this.secureCookies,
1654
+ sameSite: this.cookiePolicy.sameSite,
1655
+ ...(this.cookiePolicy.partitioned ? { partitioned: true } : {}),
1656
+ };
1657
+ }
1658
+
1380
1659
  private csrfTokenOf(tokenArg: string): string {
1381
1660
  const { csrfKey, csrfLabel } = this.requireCookieSessions();
1382
1661
  return deriveCsrfToken(tokenArg, csrfKey, csrfLabel);
@@ -1387,9 +1666,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1387
1666
  session: this.snapshot(sessionArg),
1388
1667
  csrfToken: this.csrfTokenOf(tokenArg),
1389
1668
  sessionCookie: serializeCookie(this.sessionCookieName, tokenArg, {
1390
- httpOnly: true,
1391
- secure: this.secureCookies,
1392
- sameSite: 'Lax',
1669
+ ...this.cookieAttributes(),
1393
1670
  maxAgeSeconds: maxAgeSecondsArg,
1394
1671
  }),
1395
1672
  };
@@ -1466,7 +1743,8 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1466
1743
  }
1467
1744
  try {
1468
1745
  await beforeOpenedArg?.(session);
1469
- await this.#hooks.onProviderSessionOpened?.({ session: this.snapshot(session) });
1746
+ const delivery = this.bearerDeliveryOfAttempt(attemptArg);
1747
+ await this.#hooks.onProviderSessionOpened?.({ session: this.snapshot(session), ...(delivery ? { delivery } : {}) });
1470
1748
  } catch (error) {
1471
1749
  // the cookie (or the hand-off code) never leaves, so the session is
1472
1750
  // unreachable: it is revoked and its sealed tokens erased before the
@@ -1500,8 +1778,34 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1500
1778
  };
1501
1779
  }
1502
1780
 
1503
- /** Stores the next token generation of a live session and answers its tokens; none of them is stored in the clear. */
1504
- private async issueBearerTokens(bearerArg: IBearerMode, sessionArg: RelyingPartySessionDoc): Promise<IRelyingPartyBearerTokens> {
1781
+ /**
1782
+ * Stores a new hand-off of a session, bound to the client's challenge, and
1783
+ * answers its code, which is stored only as a hash. The code lives
1784
+ * `BEARER_HANDOFF_MAX_AGE_SECONDS` at most and never past the session.
1785
+ */
1786
+ private async storeBearerHandoff(
1787
+ bearerArg: IBearerMode,
1788
+ sessionArg: RelyingPartySessionDoc,
1789
+ codeChallengeArg: string,
1790
+ ): Promise<{ code: string; expiresAt: number }> {
1791
+ const code = randomToken(32);
1792
+ const handoff = new bearerArg.models.BearerHandoff();
1793
+ handoff.id = 'rbh_' + randomToken(18);
1794
+ handoff.codeHash = sha256(code);
1795
+ handoff.sessionId = sessionArg.id;
1796
+ handoff.codeChallenge = codeChallengeArg;
1797
+ handoff.createdAt = this.#now();
1798
+ handoff.expiresAt = Math.min(handoff.createdAt + BEARER_HANDOFF_MAX_AGE_SECONDS * 1_000, sessionArg.expiresAt);
1799
+ handoff.consumedAt = null;
1800
+ await handoff.save();
1801
+ return { code, expiresAt: handoff.expiresAt };
1802
+ }
1803
+
1804
+ /** Stores the next token generation of a live session and answers its tokens and its id; no token is stored in the clear. */
1805
+ private async issueBearerTokens(
1806
+ bearerArg: IBearerMode,
1807
+ sessionArg: RelyingPartySessionDoc,
1808
+ ): Promise<{ tokens: IRelyingPartyBearerTokens; generationId: string }> {
1505
1809
  const accessToken = randomToken(32);
1506
1810
  const refreshToken = randomToken(32);
1507
1811
  const now = this.#now();
@@ -1514,14 +1818,19 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1514
1818
  generation.accessTokenExpiresAt = Math.min(now + bearerArg.accessTokenMaxAgeSeconds * 1_000, sessionArg.expiresAt);
1515
1819
  generation.expiresAt = sessionArg.expiresAt;
1516
1820
  generation.rotatedAt = null;
1821
+ generation.firstUsedAt = null;
1822
+ generation.successorId = null;
1517
1823
  await generation.save();
1518
1824
  return {
1519
- session: this.snapshot(sessionArg),
1520
- issuedAt: now,
1521
- accessToken,
1522
- accessTokenExpiresAt: generation.accessTokenExpiresAt,
1523
- refreshToken,
1524
- refreshTokenExpiresAt: generation.expiresAt,
1825
+ generationId: generation.id,
1826
+ tokens: {
1827
+ session: this.snapshot(sessionArg),
1828
+ issuedAt: now,
1829
+ accessToken,
1830
+ accessTokenExpiresAt: generation.accessTokenExpiresAt,
1831
+ refreshToken,
1832
+ refreshTokenExpiresAt: generation.expiresAt,
1833
+ },
1525
1834
  };
1526
1835
  }
1527
1836
 
@@ -1591,11 +1900,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1591
1900
  * local revocation and the provider revocation: a throw or a rejection is
1592
1901
  * reported as `hook_failed` and the end runs on.
1593
1902
  */
1594
- private notifySessionEnded(eventArg: {
1595
- sessionId: string;
1596
- reason: TRelyingPartySessionEndReason;
1597
- revokedHere: boolean;
1598
- }): void {
1903
+ private notifySessionEnded(eventArg: Parameters<NonNullable<IRelyingPartyHooks['onSessionEnded']>>[0]): void {
1599
1904
  const hook = this.#hooks.onSessionEnded;
1600
1905
  if (!hook) {
1601
1906
  return;
@@ -1626,10 +1931,11 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1626
1931
  return null;
1627
1932
  }
1628
1933
  const before = await this.revokeSessionDocument(sessionIdArg);
1629
- this.notifySessionEnded({ sessionId: sessionIdArg, reason: reasonArg, revokedHere: before !== null });
1630
1934
  if (!before) {
1935
+ this.notifySessionEnded({ sessionId: sessionIdArg, reason: reasonArg, revokedHere: false });
1631
1936
  return null;
1632
1937
  }
1938
+ this.notifySessionEnded({ sessionId: sessionIdArg, reason: reasonArg, revokedHere: true, principalId: before.userId });
1633
1939
  return { sealedAccessToken: before.sealedAccessToken, sealedRefreshToken: before.sealedRefreshToken };
1634
1940
  }
1635
1941
 
@@ -1806,9 +2112,13 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1806
2112
  * page per login start and per session the application opens. An ended
1807
2113
  * session's expiry is its revocation at the latest, so the expiry index
1808
2114
  * finds expired and revoked sessions alike. Each of them ended already: the
1809
- * row goes without an end, a hook or a provider call. A row is deleted only
1810
- * while its expiry is still past the cutoff, so a row whose expiry moved
1811
- * after the page was read stays. A sweep that cannot run never blocks a
2115
+ * row goes without an end, a hook or a provider call. Its children go
2116
+ * first: in bearer mode its hand-offs and token generations, by their
2117
+ * `sessionId` index, and only then the session row, which is the only link
2118
+ * from a principal to them; a sweep that fails half way leaves the session
2119
+ * row, and the next sweep finds it and deletes the rest. Expiry never moves
2120
+ * later, so a session past the cutoff stays past it, and its row is deleted
2121
+ * only while that still holds. A sweep that cannot run never blocks a
1812
2122
  * sign-in.
1813
2123
  */
1814
2124
  private async sweepEndedSessions(): Promise<void> {
@@ -1820,7 +2130,12 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1820
2130
  sortDirection: 'asc',
1821
2131
  limit: LOGIN_ATTEMPT_SWEEP_BATCH,
1822
2132
  });
2133
+ const bearerModels = this.#bearer?.models;
1823
2134
  for (const session of page.documents) {
2135
+ if (bearerModels) {
2136
+ await bearerModels.BearerHandoff.atomicDeleteMany({ sessionId: session.id });
2137
+ await bearerModels.BearerToken.atomicDeleteMany({ sessionId: session.id });
2138
+ }
1824
2139
  await this.#models.Session.atomicDelete({ id: session.id, expiresAt: { $lt: cutoff } });
1825
2140
  }
1826
2141
  } catch (error) {