@idp.global/sdk 19.5.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.
@@ -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 {
@@ -45,6 +46,7 @@ import type {
45
46
  IRelyingPartyBearerOptions,
46
47
  IRelyingPartyBearerReturnTargets,
47
48
  IRelyingPartyBearerTokens,
49
+ IRelyingPartyCookiePolicy,
48
50
  IRelyingPartyEvent,
49
51
  IRelyingPartyHooks,
50
52
  IRelyingPartyAdmission,
@@ -87,6 +89,10 @@ const MAX_SESSIONS_CURSOR_LENGTH = 512;
87
89
  export const BEARER_HANDOFF_MAX_AGE_SECONDS = 60;
88
90
  /** How long a bearer access token lives unless `bearer.accessTokenMaxAgeSeconds` says otherwise. */
89
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;
90
96
  /** The codes and tokens of bearer mode are 43 characters; anything far longer is none of them. */
91
97
  const MAX_BEARER_TOKEN_LENGTH = 256;
92
98
  /**
@@ -135,6 +141,14 @@ export interface IRelyingPartySessionsOptions<TPrincipal extends object | undefi
135
141
  session?: string;
136
142
  intent?: string;
137
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;
138
152
  /** the prefix of the login routes; a login never returns into it */
139
153
  loginPathPrefix?: string;
140
154
  /**
@@ -152,14 +166,15 @@ export interface IRelyingPartySessionsOptions<TPrincipal extends object | undefi
152
166
  * sign in through bearer hand-off and nothing else. The relying party never
153
167
  * reads or sets a cookie — not even the clearing intent cookie of a callback
154
168
  * 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
169
+ * `csrfKey`, `csrfLabel`, `cookieNames` and `cookiePolicy` are refused, not
170
+ * ignored. The
156
171
  * cookie steps (`openSession`, `startLogin`, `completeLogin`, `abandonLogin`,
157
172
  * `finalizeLogin`, `clearSessionCookie`, `clearIntentCookie`) throw
158
173
  * `RelyingPartyError('login_method_unavailable')` and `resolveCookieSession`
159
174
  * answers `null`.
160
175
  */
161
176
  export interface IRelyingPartyBearerOnlySessionsOptions<TPrincipal extends object | undefined = undefined>
162
- extends Omit<IRelyingPartySessionsOptions<TPrincipal>, 'provider' | 'csrfKey' | 'csrfLabel' | 'cookieNames' | 'cookieSessions' | 'bearer'> {
177
+ extends Omit<IRelyingPartySessionsOptions<TPrincipal>, 'provider' | 'csrfKey' | 'csrfLabel' | 'cookieNames' | 'cookiePolicy' | 'cookieSessions' | 'bearer'> {
163
178
  cookieSessions: false;
164
179
  /** every bearer session comes from a live provider login, directly or through a hand-off opened from one */
165
180
  provider: IOidcProvider;
@@ -167,6 +182,7 @@ export interface IRelyingPartyBearerOnlySessionsOptions<TPrincipal extends objec
167
182
  csrfKey?: never;
168
183
  csrfLabel?: never;
169
184
  cookieNames?: never;
185
+ cookiePolicy?: never;
170
186
  }
171
187
 
172
188
  type TSealedTokens = Pick<RelyingPartySessionDoc, 'sealedAccessToken' | 'sealedRefreshToken'>;
@@ -181,6 +197,8 @@ interface ICookieSessionsMode {
181
197
  interface IBearerMode {
182
198
  returnTargets: Readonly<Required<IRelyingPartyBearerReturnTargets>>;
183
199
  accessTokenMaxAgeSeconds: number;
200
+ /** `0`: a spent refresh token that comes back is always reuse */
201
+ refreshReuseGraceMs: number;
184
202
  models: IRelyingPartyBearerModels;
185
203
  }
186
204
 
@@ -207,9 +225,18 @@ const bearerModeOf = (
207
225
  ) {
208
226
  throw new Error(`bearer.accessTokenMaxAgeSeconds must be a whole number from 1 to ${optionsArg.sessionMaxAgeSeconds}`);
209
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
+ }
210
236
  return {
211
237
  returnTargets: normalizeBearerReturnTargets(bearerArg.returnTargets, optionsArg.publicOrigin, optionsArg.loginPathPrefix),
212
238
  accessTokenMaxAgeSeconds,
239
+ refreshReuseGraceMs: refreshReuseGraceSeconds * 1_000,
213
240
  models: { BearerHandoff, BearerToken },
214
241
  };
215
242
  };
@@ -286,6 +313,34 @@ const cookieNameFor = (
286
313
  return configuredArg;
287
314
  };
288
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
+
289
344
  /**
290
345
  * The relying-party session layer: provider logins through the authorization
291
346
  * code flow with PKCE, HttpOnly cookie sessions with a session-bound CSRF
@@ -312,6 +367,8 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
312
367
  public readonly secureCookies: boolean;
313
368
  public readonly sessionCookieName: string;
314
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>>;
315
372
  public readonly loginPathPrefix: string;
316
373
  public readonly sessionMaxAgeSeconds: number;
317
374
  readonly #models: IRelyingPartyModels;
@@ -341,7 +398,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
341
398
  throw new Error('csrfKey must be 32 bytes');
342
399
  }
343
400
  } else {
344
- for (const name of ['csrfKey', 'csrfLabel', 'cookieNames'] as const) {
401
+ for (const name of ['csrfKey', 'csrfLabel', 'cookieNames', 'cookiePolicy'] as const) {
345
402
  if (optionsArg[name] !== undefined) {
346
403
  throw new Error(`${name} has no use with cookieSessions: false, which reads and sets no cookie`);
347
404
  }
@@ -369,6 +426,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
369
426
  this.secureCookies,
370
427
  'intent',
371
428
  );
429
+ this.cookiePolicy = cookiePolicyOf(optionsArg.cookiePolicy, this.secureCookies);
372
430
  this.loginPathPrefix = optionsArg.loginPathPrefix ?? RELYING_PARTY_DEFAULT_LOGIN_PATH_PREFIX;
373
431
  if (!this.loginPathPrefix.startsWith('/') || !this.loginPathPrefix.endsWith('/')) {
374
432
  throw new Error('loginPathPrefix must start and end with a slash');
@@ -561,6 +619,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
561
619
  /**
562
620
  * Ends every live session of a principal but `keepSessionId` (a password
563
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.
564
623
  */
565
624
  public async endSessionsOfPrincipal(
566
625
  principalIdArg: string,
@@ -570,6 +629,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
570
629
  const sessions = await this.#models.Session.getInstances({
571
630
  userId: principalIdArg,
572
631
  revokedAt: { $exists: false },
632
+ expiresAt: { $gt: this.#now() },
573
633
  });
574
634
  for (const session of sessions) {
575
635
  if (session.id === optionsArg.keepSessionId) {
@@ -670,20 +730,12 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
670
730
 
671
731
  public clearSessionCookie(): string {
672
732
  this.requireCookieSessions();
673
- return serializeClearedCookie(this.sessionCookieName, {
674
- httpOnly: true,
675
- secure: this.secureCookies,
676
- sameSite: 'Lax',
677
- });
733
+ return serializeClearedCookie(this.sessionCookieName, this.cookieAttributes());
678
734
  }
679
735
 
680
736
  public clearIntentCookie(): string {
681
737
  this.requireCookieSessions();
682
- return serializeClearedCookie(this.intentCookieName, {
683
- httpOnly: true,
684
- secure: this.secureCookies,
685
- sameSite: 'Lax',
686
- });
738
+ return serializeClearedCookie(this.intentCookieName, this.cookieAttributes());
687
739
  }
688
740
 
689
741
  // ==========================================================================
@@ -717,9 +769,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
717
769
  return {
718
770
  authorizationUrl,
719
771
  intentCookie: serializeCookie(this.intentCookieName, bindingToken, {
720
- httpOnly: true,
721
- secure: this.secureCookies,
722
- sameSite: 'Lax',
772
+ ...this.cookieAttributes(),
723
773
  maxAgeSeconds: LOGIN_ATTEMPT_MAX_AGE_SECONDS,
724
774
  }),
725
775
  };
@@ -1057,16 +1107,23 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1057
1107
  if (!live) {
1058
1108
  throw new RelyingPartyError('invalid_bearer_handoff');
1059
1109
  }
1060
- return this.issueBearerTokens(bearer, live.document);
1110
+ return (await this.issueBearerTokens(bearer, live.document)).tokens;
1061
1111
  }
1062
1112
 
1063
1113
  /**
1064
1114
  * Rotates a bearer session's tokens: the refresh token is spent, a new
1065
1115
  * access and refresh token answer, and the old access token stops working.
1066
- * Rotation is strict — a client serializes its refreshes — because a
1067
- * refresh token that comes back after it was spent ends the whole session.
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
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
1070
1127
  * unreachable provider fails with `AuthorityTokenError('authority_unavailable')`
1071
1128
  * and spends nothing. A session `openBearerHandoff` opened has no provider
1072
1129
  * login (no `idpSubject` is stored) and rotates without the provider.
@@ -1082,13 +1139,20 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1082
1139
  if (!current) {
1083
1140
  throw new RelyingPartyError('invalid_bearer_grant');
1084
1141
  }
1085
- if (current.rotatedAt !== null) {
1142
+ const lostSuccessor = current.rotatedAt !== null ? await this.lostSuccessorOf(bearer, current) : null;
1143
+ if (current.rotatedAt !== null && !lostSuccessor) {
1086
1144
  await this.bearerCredentialReused(current.sessionId, 'refresh_token_reused');
1087
1145
  throw new RelyingPartyError('invalid_bearer_grant');
1088
1146
  }
1089
1147
  if (current.expiresAt <= this.#now()) {
1090
1148
  throw new RelyingPartyError('invalid_bearer_grant');
1091
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
+ }
1092
1156
  const live = await this.liveSession(await this.#models.Session.getInstance({ id: current.sessionId }));
1093
1157
  if (!live) {
1094
1158
  throw new RelyingPartyError('invalid_bearer_grant');
@@ -1105,20 +1169,105 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1105
1169
  throw error;
1106
1170
  }
1107
1171
  }
1172
+ if (lostSuccessor) {
1173
+ return this.reissueLostRotation(bearer, current, lostSuccessor, live.document);
1174
+ }
1108
1175
  // the next generation first: a rotation that fails half way leaves an
1109
1176
  // unknown generation behind, never a session without a current one
1110
1177
  const issued = await this.issueBearerTokens(bearer, live.document);
1111
1178
  const rotated = await bearer.models.BearerToken.atomicUpdate(
1112
1179
  { refreshTokenHash, rotatedAt: null },
1113
- { $set: { rotatedAt: this.#now() } },
1180
+ { $set: { rotatedAt: this.#now(), successorId: issued.generationId } },
1114
1181
  );
1115
1182
  if (rotated.matchedCount !== 1) {
1116
- // a concurrent refresh spent it first; the generation just issued never
1117
- // 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
1118
1186
  await this.bearerCredentialReused(current.sessionId, 'refresh_token_reused');
1119
1187
  throw new RelyingPartyError('invalid_bearer_grant');
1120
1188
  }
1121
- 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;
1122
1271
  }
1123
1272
 
1124
1273
  /**
@@ -1136,7 +1285,11 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1136
1285
  return null;
1137
1286
  }
1138
1287
  const live = await this.liveSession(await this.#models.Session.getInstance({ id: generation.sessionId }));
1139
- 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 };
1140
1293
  }
1141
1294
 
1142
1295
  /** `resolveBearerAccessToken` for an `Authorization: Bearer <token>` header. */
@@ -1149,7 +1302,9 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1149
1302
  /**
1150
1303
  * Ends the session of a bearer token — a live access token or the current
1151
1304
  * refresh token — like `endSession`. A refresh token that was already spent
1152
- * 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.
1153
1308
  */
1154
1309
  public async endBearerSession(tokenArg: unknown): Promise<boolean> {
1155
1310
  const token = this.#bearer ? bearerCredentialOf(tokenArg) : null;
@@ -1168,7 +1323,8 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1168
1323
  if (!byRefreshToken) {
1169
1324
  return false;
1170
1325
  }
1171
- 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))) {
1172
1328
  return this.bearerCredentialReused(byRefreshToken.sessionId, 'refresh_token_reused');
1173
1329
  }
1174
1330
  return this.endSession(byRefreshToken.sessionId, 'logout');
@@ -1490,6 +1646,16 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1490
1646
  return session;
1491
1647
  }
1492
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
+
1493
1659
  private csrfTokenOf(tokenArg: string): string {
1494
1660
  const { csrfKey, csrfLabel } = this.requireCookieSessions();
1495
1661
  return deriveCsrfToken(tokenArg, csrfKey, csrfLabel);
@@ -1500,9 +1666,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1500
1666
  session: this.snapshot(sessionArg),
1501
1667
  csrfToken: this.csrfTokenOf(tokenArg),
1502
1668
  sessionCookie: serializeCookie(this.sessionCookieName, tokenArg, {
1503
- httpOnly: true,
1504
- secure: this.secureCookies,
1505
- sameSite: 'Lax',
1669
+ ...this.cookieAttributes(),
1506
1670
  maxAgeSeconds: maxAgeSecondsArg,
1507
1671
  }),
1508
1672
  };
@@ -1637,8 +1801,11 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1637
1801
  return { code, expiresAt: handoff.expiresAt };
1638
1802
  }
1639
1803
 
1640
- /** Stores the next token generation of a live session and answers its tokens; none of them is stored in the clear. */
1641
- private async issueBearerTokens(bearerArg: IBearerMode, sessionArg: RelyingPartySessionDoc): Promise<IRelyingPartyBearerTokens> {
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 }> {
1642
1809
  const accessToken = randomToken(32);
1643
1810
  const refreshToken = randomToken(32);
1644
1811
  const now = this.#now();
@@ -1651,14 +1818,19 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1651
1818
  generation.accessTokenExpiresAt = Math.min(now + bearerArg.accessTokenMaxAgeSeconds * 1_000, sessionArg.expiresAt);
1652
1819
  generation.expiresAt = sessionArg.expiresAt;
1653
1820
  generation.rotatedAt = null;
1821
+ generation.firstUsedAt = null;
1822
+ generation.successorId = null;
1654
1823
  await generation.save();
1655
1824
  return {
1656
- session: this.snapshot(sessionArg),
1657
- issuedAt: now,
1658
- accessToken,
1659
- accessTokenExpiresAt: generation.accessTokenExpiresAt,
1660
- refreshToken,
1661
- 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
+ },
1662
1834
  };
1663
1835
  }
1664
1836
 
@@ -1940,9 +2112,13 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1940
2112
  * page per login start and per session the application opens. An ended
1941
2113
  * session's expiry is its revocation at the latest, so the expiry index
1942
2114
  * 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
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
1946
2122
  * sign-in.
1947
2123
  */
1948
2124
  private async sweepEndedSessions(): Promise<void> {
@@ -1954,7 +2130,12 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
1954
2130
  sortDirection: 'asc',
1955
2131
  limit: LOGIN_ATTEMPT_SWEEP_BATCH,
1956
2132
  });
2133
+ const bearerModels = this.#bearer?.models;
1957
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
+ }
1958
2139
  await this.#models.Session.atomicDelete({ id: session.id, expiresAt: { $lt: cutoff } });
1959
2140
  }
1960
2141
  } catch (error) {
@@ -129,7 +129,9 @@ export class RelyingPartyLoginAttemptDoc extends plugins.nosqldb.SmartDataDbDoc<
129
129
  /**
130
130
  * The single-use code a bearer login hands its client. Only the hash of the
131
131
  * code is stored; the code is bound to the client's S256 challenge, lives at
132
- * most a minute and is consumed by one conditional update.
132
+ * most a minute and is consumed by one conditional update. The `sessionId`
133
+ * index is what the session sweep and the erase delete a session's hand-offs
134
+ * by.
133
135
  */
134
136
  export class RelyingPartyBearerHandoffDoc extends plugins.nosqldb.SmartDataDbDoc<RelyingPartyBearerHandoffDoc, RelyingPartyBearerHandoffDoc> {
135
137
  @plugins.nosqldb.unI()
@@ -141,6 +143,7 @@ export class RelyingPartyBearerHandoffDoc extends plugins.nosqldb.SmartDataDbDoc
141
143
 
142
144
  /** the session the login opened; the code is its only way out */
143
145
  @plugins.nosqldb.svDb()
146
+ @plugins.nosqldb.index()
144
147
  public sessionId: string = '';
145
148
 
146
149
  @plugins.nosqldb.svDb()
@@ -163,8 +166,9 @@ export class RelyingPartyBearerHandoffDoc extends plugins.nosqldb.SmartDataDbDoc
163
166
  * One generation of a bearer session's tokens: an access token and the
164
167
  * refresh token that replaces both. Only hashes are stored. Exactly one
165
168
  * generation of a session is current (`rotatedAt` `null`); a rotated one is
166
- * kept until the session expires, so its refresh token coming back is
167
- * recognized as reuse.
169
+ * kept until its session is swept, so its refresh token coming back is
170
+ * recognized as reuse. The `sessionId` index is what the session sweep and
171
+ * the erase delete a session's generations by.
168
172
  */
169
173
  export class RelyingPartyBearerTokenDoc extends plugins.nosqldb.SmartDataDbDoc<RelyingPartyBearerTokenDoc, RelyingPartyBearerTokenDoc> {
170
174
  @plugins.nosqldb.unI()
@@ -177,6 +181,7 @@ export class RelyingPartyBearerTokenDoc extends plugins.nosqldb.SmartDataDbDoc<R
177
181
  public refreshTokenHash: string = '';
178
182
 
179
183
  @plugins.nosqldb.svDb()
184
+ @plugins.nosqldb.index()
180
185
  public sessionId: string = '';
181
186
 
182
187
  @plugins.nosqldb.svDb()
@@ -190,9 +195,27 @@ export class RelyingPartyBearerTokenDoc extends plugins.nosqldb.SmartDataDbDoc<R
190
195
  @plugins.nosqldb.index()
191
196
  public expiresAt: number = 0;
192
197
 
193
- /** `null` while this generation is current; when its refresh token was spent otherwise */
198
+ /** `null` while this generation is current; when its refresh token was spent, or the generation revoked, otherwise */
194
199
  @plugins.nosqldb.svDb()
195
200
  public rotatedAt: number | null = null;
201
+
202
+ /**
203
+ * When its access token or its refresh token was first presented; `null`
204
+ * until then (and on a generation stored before 19.6.0). A rotated
205
+ * generation whose successor was never presented may come back within the
206
+ * reuse grace window as a lost answer.
207
+ */
208
+ @plugins.nosqldb.svDb()
209
+ public firstUsedAt: number | null = null;
210
+
211
+ /**
212
+ * The generation that replaced this one: set by its rotation and moved to
213
+ * the new one when a lost answer is issued again. `null` on a current
214
+ * generation, on one that was revoked unpresented, and on one stored before
215
+ * 19.6.0.
216
+ */
217
+ @plugins.nosqldb.svDb()
218
+ public successorId: string | null = null;
196
219
  }
197
220
 
198
221
  export const RELYING_PARTY_DEFAULT_COLLECTION_NAMES = {
@@ -3,6 +3,12 @@ export interface ICookieOptions {
3
3
  maxAgeSeconds?: number;
4
4
  sameSite?: 'Lax' | 'Strict' | 'None';
5
5
  secure?: boolean;
6
+ /**
7
+ * `Partitioned` (CHIPS): the browser keeps the cookie per top-level site, so
8
+ * a page framed on another site gets a cookie jar of its own there. Needs
9
+ * `secure`; a cookie set partitioned is also cleared partitioned.
10
+ */
11
+ partitioned?: boolean;
6
12
  }
7
13
 
8
14
  // Control characters, space, DEL and the cookie delimiters are never valid.
@@ -60,6 +66,13 @@ export const serializeCookie = (
60
66
  if (optionsArg.sameSite) {
61
67
  parts.push('SameSite=' + optionsArg.sameSite);
62
68
  }
69
+ if (optionsArg.partitioned) {
70
+ if (!optionsArg.secure) {
71
+ // browsers drop a partitioned cookie that is not set Secure
72
+ throw new Error('A Partitioned cookie needs Secure');
73
+ }
74
+ parts.push('Partitioned');
75
+ }
63
76
  if (optionsArg.maxAgeSeconds !== undefined) {
64
77
  if (!Number.isSafeInteger(optionsArg.maxAgeSeconds) || optionsArg.maxAgeSeconds < 0) {
65
78
  throw new Error('Invalid cookie max age');
@@ -45,6 +45,27 @@ export interface IResolvedRelyingPartySession<TPrincipal extends object | undefi
45
45
  csrfToken: string;
46
46
  }
47
47
 
48
+ /**
49
+ * How the session cookie and the login's intent cookie are sent across
50
+ * sites; one policy for both, on every cookie they are set and cleared with.
51
+ */
52
+ export interface IRelyingPartyCookiePolicy {
53
+ /**
54
+ * `Lax`, the default: the browser sends the cookies with this origin's own
55
+ * requests and with a top-level navigation to it, never into a frame on
56
+ * another site. `None`: also for a page of this origin framed on another
57
+ * site; it needs an https `publicOrigin`, because a `SameSite=None` cookie
58
+ * must be `Secure`.
59
+ */
60
+ sameSite?: 'Lax' | 'None';
61
+ /**
62
+ * `Partitioned` (CHIPS): the browser keeps the cookies per top-level site,
63
+ * so the framed application has a jar of its own under each site that frames
64
+ * it. Only with `sameSite: 'None'`. `false` by default.
65
+ */
66
+ partitioned?: boolean;
67
+ }
68
+
48
69
  /** A session just opened: the answer, the CSRF token and the `Set-Cookie` value that carries it. */
49
70
  export interface IRelyingPartySessionOpening {
50
71
  session: IRelyingPartySession;
@@ -119,6 +140,15 @@ export interface IRelyingPartyBearerOptions {
119
140
  returnTargets: IRelyingPartyBearerReturnTargets;
120
141
  /** how long an access token lives, in seconds; 900 by default, never past its session */
121
142
  accessTokenMaxAgeSeconds?: number;
143
+ /**
144
+ * How long after a rotation its spent refresh token may come back as a lost
145
+ * answer instead of reuse, in whole seconds: 60 by default, 0 turns it off,
146
+ * at most 300. Within it, a spent refresh token whose successor was never
147
+ * presented (neither its access token nor its refresh token) revokes that
148
+ * successor and answers a new pair; once the successor was presented, or
149
+ * after the window, it is reuse and ends the session.
150
+ */
151
+ refreshReuseGraceSeconds?: number;
122
152
  }
123
153
 
124
154
  /** What a bearer client sends to start its login. Every field is checked as untrusted input. */
@@ -310,6 +340,8 @@ export type TRelyingPartyEventName =
310
340
  | 'session_sweep_failed'
311
341
  /** a bearer hand-off code or refresh token came back after its use; `reason` names which, the session ended */
312
342
  | 'bearer_credential_reused'
343
+ /** a spent refresh token came back within `refreshReuseGraceSeconds` before its successor was presented: the successor was revoked and a new pair issued */
344
+ | 'bearer_refresh_reissued'
313
345
  /** an application hook threw; the step it belonged to ran on regardless */
314
346
  | 'hook_failed'
315
347
  /** a session whose opening failed could not be revoked; its provider tokens went back anyway */