matrix-js-sdk 42.3.0 → 42.4.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 (96) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/lib/@types/json.d.ts.map +1 -1
  3. package/lib/@types/json.js.map +1 -1
  4. package/lib/client.d.ts +74 -5
  5. package/lib/client.d.ts.map +1 -1
  6. package/lib/client.js +83 -7
  7. package/lib/client.js.map +1 -1
  8. package/lib/common-crypto/CryptoBackend.d.ts +42 -23
  9. package/lib/common-crypto/CryptoBackend.d.ts.map +1 -1
  10. package/lib/common-crypto/CryptoBackend.js +7 -0
  11. package/lib/common-crypto/CryptoBackend.js.map +1 -1
  12. package/lib/embedded.d.ts +35 -1
  13. package/lib/embedded.d.ts.map +1 -1
  14. package/lib/embedded.js +49 -1
  15. package/lib/embedded.js.map +1 -1
  16. package/lib/http-api/fetch.d.ts +0 -1
  17. package/lib/http-api/fetch.d.ts.map +1 -1
  18. package/lib/http-api/fetch.js +3 -22
  19. package/lib/http-api/fetch.js.map +1 -1
  20. package/lib/http-api/logging.d.ts +10 -0
  21. package/lib/http-api/logging.d.ts.map +1 -0
  22. package/lib/http-api/logging.js +46 -0
  23. package/lib/http-api/logging.js.map +1 -0
  24. package/lib/matrixrtc/EncryptionManager.d.ts +5 -0
  25. package/lib/matrixrtc/EncryptionManager.d.ts.map +1 -1
  26. package/lib/matrixrtc/EncryptionManager.js.map +1 -1
  27. package/lib/matrixrtc/LivekitTransport.d.ts +82 -0
  28. package/lib/matrixrtc/LivekitTransport.d.ts.map +1 -1
  29. package/lib/matrixrtc/LivekitTransport.js +25 -0
  30. package/lib/matrixrtc/LivekitTransport.js.map +1 -1
  31. package/lib/matrixrtc/MatrixRTCSession.d.ts +41 -2
  32. package/lib/matrixrtc/MatrixRTCSession.d.ts.map +1 -1
  33. package/lib/matrixrtc/MatrixRTCSession.js +59 -1
  34. package/lib/matrixrtc/MatrixRTCSession.js.map +1 -1
  35. package/lib/matrixrtc/MembershipManager.js +7 -4
  36. package/lib/matrixrtc/MembershipManager.js.map +1 -1
  37. package/lib/matrixrtc/RTCEncryptionManager.d.ts +17 -0
  38. package/lib/matrixrtc/RTCEncryptionManager.d.ts.map +1 -1
  39. package/lib/matrixrtc/RTCEncryptionManager.js +68 -29
  40. package/lib/matrixrtc/RTCEncryptionManager.js.map +1 -1
  41. package/lib/models/room-receipts.d.ts +9 -3
  42. package/lib/models/room-receipts.d.ts.map +1 -1
  43. package/lib/models/room-receipts.js +51 -11
  44. package/lib/models/room-receipts.js.map +1 -1
  45. package/lib/oauth/authorize.d.ts +7 -2
  46. package/lib/oauth/authorize.d.ts.map +1 -1
  47. package/lib/oauth/authorize.js +10 -4
  48. package/lib/oauth/authorize.js.map +1 -1
  49. package/lib/oauth/fetch.d.ts +17 -0
  50. package/lib/oauth/fetch.d.ts.map +1 -0
  51. package/lib/oauth/fetch.js +47 -0
  52. package/lib/oauth/fetch.js.map +1 -0
  53. package/lib/oauth/index.d.ts +15 -2
  54. package/lib/oauth/index.d.ts.map +1 -1
  55. package/lib/oauth/index.js +33 -12
  56. package/lib/oauth/index.js.map +1 -1
  57. package/lib/rust-crypto/index.d.ts +17 -0
  58. package/lib/rust-crypto/index.d.ts.map +1 -1
  59. package/lib/rust-crypto/index.js +4 -2
  60. package/lib/rust-crypto/index.js.map +1 -1
  61. package/lib/rust-crypto/rust-crypto.d.ts +6 -26
  62. package/lib/rust-crypto/rust-crypto.d.ts.map +1 -1
  63. package/lib/rust-crypto/rust-crypto.js +19 -54
  64. package/lib/rust-crypto/rust-crypto.js.map +1 -1
  65. package/lib/sliding-sync-sdk.d.ts.map +1 -1
  66. package/lib/sliding-sync-sdk.js +65 -27
  67. package/lib/sliding-sync-sdk.js.map +1 -1
  68. package/lib/sliding-sync.d.ts +17 -0
  69. package/lib/sliding-sync.d.ts.map +1 -1
  70. package/lib/sliding-sync.js +21 -9
  71. package/lib/sliding-sync.js.map +1 -1
  72. package/lib/sync.d.ts +9 -1
  73. package/lib/sync.d.ts.map +1 -1
  74. package/lib/sync.js +54 -29
  75. package/lib/sync.js.map +1 -1
  76. package/package.json +5 -5
  77. package/src/@types/json.ts +11 -2
  78. package/src/client.ts +126 -11
  79. package/src/common-crypto/CryptoBackend.ts +45 -23
  80. package/src/embedded.ts +67 -4
  81. package/src/http-api/fetch.ts +2 -23
  82. package/src/http-api/logging.ts +46 -0
  83. package/src/matrixrtc/EncryptionManager.ts +6 -0
  84. package/src/matrixrtc/LivekitTransport.ts +86 -0
  85. package/src/matrixrtc/MatrixRTCSession.ts +81 -1
  86. package/src/matrixrtc/MembershipManager.ts +7 -7
  87. package/src/matrixrtc/RTCEncryptionManager.ts +80 -31
  88. package/src/models/room-receipts.ts +54 -10
  89. package/src/oauth/authorize.ts +10 -2
  90. package/src/oauth/fetch.ts +54 -0
  91. package/src/oauth/index.ts +29 -10
  92. package/src/rust-crypto/index.ts +23 -0
  93. package/src/rust-crypto/rust-crypto.ts +25 -67
  94. package/src/sliding-sync-sdk.ts +73 -33
  95. package/src/sliding-sync.ts +31 -14
  96. package/src/sync.ts +58 -35
@@ -26,7 +26,7 @@ import { CallMembership } from "./CallMembership.ts";
26
26
  import { RoomStateEvent } from "../models/room-state.ts";
27
27
  import { MembershipManager, StickyEventMembershipManager } from "./MembershipManager.ts";
28
28
  import { type CallMembershipIdentityParts, type IEncryptionManager } from "./EncryptionManager.ts";
29
- import { logDurationSync } from "../utils.ts";
29
+ import { deepCompare, logDurationSync } from "../utils.ts";
30
30
  import type {
31
31
  Statistics,
32
32
  RTCNotificationType,
@@ -36,6 +36,7 @@ import type {
36
36
  Transport,
37
37
  SlotDescription,
38
38
  RtcSlotEventContent,
39
+ RtcSlotEncryptionContent,
39
40
  } from "./types.ts";
40
41
  import {
41
42
  MembershipManagerEvent,
@@ -65,6 +66,8 @@ export enum MatrixRTCSessionEvent {
65
66
  MembershipManagerError = "membership_manager_error",
66
67
  /** The RTCSession did send a call notification caused by joining the call as the first member */
67
68
  DidSendCallNotification = "did_send_call_notification",
69
+ /** Whether the session is too large for the media key to be rotated has changed */
70
+ KeyRotationSuppressedChanged = "key_rotation_suppressed_changed",
68
71
  }
69
72
 
70
73
  export type MatrixRTCSessionEventHandlerMap = {
@@ -83,6 +86,7 @@ export type MatrixRTCSessionEventHandlerMap = {
83
86
  [MatrixRTCSessionEvent.DidSendCallNotification]: (
84
87
  notificationContentNew: { event_id: string } & IRTCNotificationContent,
85
88
  ) => void;
89
+ [MatrixRTCSessionEvent.KeyRotationSuppressedChanged]: (isKeyRotationSuppressed: boolean) => void;
86
90
  };
87
91
 
88
92
  export interface SessionConfig {
@@ -204,6 +208,17 @@ export interface EncryptionConfig {
204
208
  */
205
209
  keyRotationGracePeriodMs?: number;
206
210
 
211
+ /**
212
+ * The number of participants in the session at which the media key will no longer be rotated.
213
+ *
214
+ * Rotating a key requires sending it to every participant device, so in large sessions the cost of rotating
215
+ * on every join/leave becomes prohibitive. At this limit the current key is kept and
216
+ * distributed to new joiners. No new keys are generated for joiners/leavers.
217
+ *
218
+ * Defaults to undefined.
219
+ */
220
+ keyRotationParticipantLimit?: number;
221
+
207
222
  /**
208
223
  * The delay (in milliseconds) after a member leaves before we create and publish a new key, because people
209
224
  * tend to leave calls at the same time.
@@ -296,6 +311,14 @@ export class MatrixRTCSession extends TypedEventEmitter<
296
311
  return this.membershipManager?.delayId;
297
312
  }
298
313
 
314
+ /**
315
+ * Whether the key rotation is currently halted.
316
+ * mirror of: EncryptionConfig.keyRotationParticipantLimit
317
+ */
318
+ public get isKeyRotationSuppressed(): boolean {
319
+ return this.encryptionManager?.isKeyRotationSuppressed ?? false;
320
+ }
321
+
299
322
  /**
300
323
  * The callId (sessionId) of the call.
301
324
  *
@@ -335,6 +358,55 @@ export class MatrixRTCSession extends TypedEventEmitter<
335
358
  return isSlotClosed(this.roomSubset, this.slotDescription);
336
359
  }
337
360
 
361
+ /**
362
+ * Ensures this session's slot is open, sending a slot state event to open (or create) it if needed.
363
+ *
364
+ * The event's `application` is set from this session's slot description and its `encryption` from
365
+ * `opts.encryption`, replacing whatever an existing slot event declares. Other content is preserved.
366
+ * No-op if the slot is already open with matching `application` and `encryption`.
367
+ *
368
+ * @param opts.encryption - The encryption to declare on the slot, or `undefined` for none.
369
+ * @throws if sending the state event fails.
370
+ */
371
+ public async ensureRtcSlotOpen(opts: { encryption?: RtcSlotEncryptionContent } = {}): Promise<void> {
372
+ const application = { type: this.slotDescription.application };
373
+ const existingContent = this.getRtcSlot();
374
+ if (
375
+ existingContent?.status === "open" &&
376
+ deepCompare(existingContent.application, application) &&
377
+ deepCompare(existingContent.encryption, opts.encryption)
378
+ ) {
379
+ return;
380
+ }
381
+ await this.sendRtcSlot({
382
+ ...existingContent,
383
+ status: "open",
384
+ application,
385
+ encryption: opts.encryption,
386
+ });
387
+ }
388
+
389
+ /**
390
+ * Ensures this session's slot is closed, sending a slot state event to close it if needed.
391
+ * The other content of the existing slot event is preserved. No-op if no slot event exists.
392
+ *
393
+ * @throws if sending the state event fails.
394
+ */
395
+ public async ensureRtcSlotClosed(): Promise<void> {
396
+ const existingContent = this.getRtcSlot();
397
+ if (!existingContent || existingContent.status === "closed") return;
398
+ await this.sendRtcSlot({ ...existingContent, status: "closed" });
399
+ }
400
+
401
+ private async sendRtcSlot(content: RtcSlotEventContent): Promise<void> {
402
+ await this.client.sendStateEvent(
403
+ this.roomSubset.roomId,
404
+ EventType.RTCSlot,
405
+ content,
406
+ computeSlotId(this.slotDescription),
407
+ );
408
+ }
409
+
338
410
  /**
339
411
  * Returns all the call memberships for a room that match the provided `sessionDescription`,
340
412
  * oldest first.
@@ -810,6 +882,8 @@ export class MatrixRTCSession extends TypedEventEmitter<
810
882
  // Clear the flag.
811
883
  this.membershipNeedsRecalculation = false;
812
884
  const oldMemberships = this.memberships;
885
+ // Needs to be computed before `this.memberships` is updated below, since it is derived from it.
886
+ const wasKeyRotationSuppressed = this.isKeyRotationSuppressed;
813
887
 
814
888
  this.memberships = await MatrixRTCSession.sessionMembershipsForSlot(
815
889
  this.room,
@@ -854,6 +928,12 @@ export class MatrixRTCSession extends TypedEventEmitter<
854
928
  // This also needs to be done if `changed` = false
855
929
  // A member might have updated their fingerprint (created_ts)
856
930
  this.encryptionManager?.onMembershipsUpdate(oldMemberships);
931
+ if (this.isKeyRotationSuppressed !== wasKeyRotationSuppressed) {
932
+ this.logger.info(
933
+ `Key rotation is now ${this.isKeyRotationSuppressed ? "suppressed" : "active again"} (${this.memberships.length} members)`,
934
+ );
935
+ this.emit(MatrixRTCSessionEvent.KeyRotationSuppressedChanged, this.isKeyRotationSuppressed);
936
+ }
857
937
 
858
938
  this.setExpiryTimer();
859
939
  };
@@ -919,15 +919,15 @@ export class MembershipManager
919
919
  const retryCounterString = "(" + retries + "/" + this.maximumNetworkErrorRetryCount + ")";
920
920
 
921
921
  // Variables for scheduling the new event
922
- let retryDuration = this.networkErrorRetryMs;
922
+ const retryDuration = this.networkErrorRetryMs;
923
923
 
924
924
  if (error instanceof Error && error.name === "AbortError") {
925
- // We do not wait for the timeout on local timeouts.
926
- retryDuration = 0;
927
- this.logger.warn(
928
- "Network local timeout error while sending event, immediate retry (" + retryCounterString + ")",
929
- error,
930
- );
925
+ // A local timeout means the server accepted the request but is slow to answer. Retry immediately and
926
+ // do not count it towards the fatal retry limit: the server's delayed leave already bounds how long a
927
+ // stalled restart can go unnoticed, and the resulting forced re-join recovers the membership. Giving
928
+ // up here would only replace a temporary media pause with a "connection lost" error for the user.
929
+ this.logger.warn("Network local timeout error while sending event, immediate retry", error);
930
+ return createInsertActionUpdate(type, 0);
931
931
  } else if (error instanceof Error && error.message.includes("updating delayed event")) {
932
932
  // TODO: We do not want error message matching here but instead the error should be a typed HTTPError
933
933
  // and be handled below automatically (the same as in the SPA case).
@@ -34,6 +34,17 @@ import {
34
34
  import { OutdatedKeyFilter } from "./utils.ts";
35
35
  import { computeRtcIdentityRaw } from "./membershipData/rtc.ts";
36
36
 
37
+ /**
38
+ * Default for {@link EncryptionConfig.keyRotationParticipantLimit}.
39
+ *
40
+ * Setting this to undefined implies that we do not have a limit and full rotations are always done.
41
+ * This is the most secure and least performant option.
42
+ * It is highly recommended to set this to < 50 for client deployments that are planned to be used for large calls.
43
+ * But before setting this, make yourself familiar with the exact security implications.
44
+ * Key, rotations will stop when reaching this user limit in a call. The call will still be encrypted.
45
+ */
46
+ const DEFAULT_KEY_ROTATION_PARTICIPANT_LIMIT: number | undefined = undefined;
47
+
37
48
  /**
38
49
  * RTCEncryptionManager is used to manage the encryption keys for a call.
39
50
  *
@@ -93,6 +104,14 @@ export class RTCEncryptionManager implements IEncryptionManager {
93
104
  */
94
105
  private keyRotationGracePeriodMs = 10_000;
95
106
 
107
+ /**
108
+ * The number of participants at or above which we stop rotating the key altogether.
109
+ * The current key is still distributed to new joiners, but no new key is generated.
110
+ * @see EncryptionConfig.keyRotationParticipantLimit
111
+ * @private
112
+ */
113
+ private keyRotationParticipantLimit = DEFAULT_KEY_ROTATION_PARTICIPANT_LIMIT;
114
+
96
115
  /**
97
116
  * If a new key distribution is being requested while one is going on, we will set this flag to true.
98
117
  * This will ensure that a new round is started after the current one.
@@ -141,6 +160,21 @@ export class RTCEncryptionManager implements IEncryptionManager {
141
160
  this.rtcIdentityProvider = rtcBackendIdProvider ?? computeRtcIdentityRaw;
142
161
  }
143
162
 
163
+ /**
164
+ * Whether the session currently has too many participants for the key to be rotated.
165
+ *
166
+ * This is computed by checking the participant count. If there are too many participants for efficient rotations,
167
+ * the key rotation will be suppressed.
168
+ * While this is true, the current key is still shared with new joiners and the current call is still fully encrypted,
169
+ * but no new key is generated for joiners or leavers. Changes are signalled by {@link MatrixRTCSessionEvent.KeyRotationSuppressedChanged}.
170
+ * @see EncryptionConfig.keyRotationParticipantLimit
171
+ */
172
+ public get isKeyRotationSuppressed(): boolean {
173
+ if (!this.manageMediaKeys) return false;
174
+ if (this.keyRotationParticipantLimit === undefined) return false;
175
+ return this.getMemberships().length >= this.keyRotationParticipantLimit;
176
+ }
177
+
144
178
  private async getOwnRtcBackendIdentity(): Promise<string> {
145
179
  if (this.ownRtcBackendIdentityCache) return this.ownRtcBackendIdentityCache;
146
180
 
@@ -221,6 +255,8 @@ export class RTCEncryptionManager implements IEncryptionManager {
221
255
  this.useHashedRtcBackendIdentity = joinConfig?.unstableSendStickyEvents ?? false;
222
256
  this.useKeyDelay = joinConfig?.useKeyDelay ?? 1000;
223
257
  this.keyRotationGracePeriodMs = joinConfig?.keyRotationGracePeriodMs ?? 10_000;
258
+ this.keyRotationParticipantLimit =
259
+ joinConfig?.keyRotationParticipantLimit ?? DEFAULT_KEY_ROTATION_PARTICIPANT_LIMIT;
224
260
 
225
261
  this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
226
262
  void this.getOwnRtcBackendIdentity(); // precompute own identity
@@ -374,53 +410,66 @@ export class RTCEncryptionManager implements IEncryptionManager {
374
410
  );
375
411
 
376
412
  let toDistributeTo: ParticipantDeviceInfo[] = [];
377
- let outboundKey: OutboundEncryptionSession;
413
+ //default to current session
414
+ let newOutboundEncryptionSession: OutboundEncryptionSession = this.outboundSession!;
378
415
  let hasKeyChanged = false;
379
- if (anyLeft.length > 0) {
380
- // We need to rotate the key
381
- const newOutboundKey = this.createNewOutboundSession();
382
- hasKeyChanged = true;
383
- toDistributeTo = toShareWith;
384
- outboundKey = newOutboundKey;
385
- } else if (anyJoined.length > 0) {
386
- const now = Date.now();
387
- const keyAge = now - this.outboundSession!.creationTS;
388
- // If the current key is recently created (less than `keyRotationGracePeriodMs`), we can keep it and just distribute it to the new joiners.
389
- if (keyAge < this.keyRotationGracePeriodMs) {
390
- // keep the same key
391
- // XXX In the future we want to distribute a ratcheted key, not the current one
392
- this.logger?.debug(`New joiners detected, but the key is recent enough (age:${keyAge}), keeping it`);
393
- toDistributeTo = anyJoined;
394
- outboundKey = this.outboundSession!;
395
- } else {
416
+
417
+ // Rotating means sending the new key to every participant, this is expensive in large session.
418
+ if (this.isKeyRotationSuppressed) {
419
+ if (anyJoined.length > 0) {
420
+ this.logger?.debug(
421
+ `New joiners detected, but the session has ${toShareWith.length} participants (limit:${this.keyRotationParticipantLimit}), keeping the key`,
422
+ );
423
+ }
424
+ toDistributeTo = anyJoined;
425
+ } else {
426
+ if (anyLeft.length > 0) {
396
427
  // We need to rotate the key
397
- this.logger?.debug(`New joiners detected, rotating the key`);
398
- const newOutboundKey = this.createNewOutboundSession();
428
+ newOutboundEncryptionSession = this.createNewOutboundSession();
399
429
  hasKeyChanged = true;
400
430
  toDistributeTo = toShareWith;
401
- outboundKey = newOutboundKey;
431
+ } else if (anyJoined.length > 0) {
432
+ const keyAge = Date.now() - this.outboundSession!.creationTS;
433
+ if (keyAge < this.keyRotationGracePeriodMs) {
434
+ this.logger?.debug(
435
+ `New joiners detected, but the key is recent enough (age:${keyAge}), keeping it`,
436
+ );
437
+ toDistributeTo = anyJoined;
438
+ } else {
439
+ this.logger?.debug(`New joiners detected, rotating the key`);
440
+ // We need to rotate the key
441
+ newOutboundEncryptionSession = this.createNewOutboundSession();
442
+ hasKeyChanged = true;
443
+ toDistributeTo = toShareWith;
444
+ }
402
445
  }
403
- } else {
404
- // no changes
446
+ }
447
+ // return early if we dont have anything to distribute.
448
+ if (toDistributeTo.length === 0) {
405
449
  return;
406
450
  }
407
451
 
408
452
  try {
409
453
  this.logger?.trace(`Sending key...`);
410
- await this.transport.sendKey(encodeBase64(outboundKey.key), outboundKey.keyId, toDistributeTo);
411
- outboundKey.sharedWith.push(...toDistributeTo);
412
- this.logger?.trace(
413
- `key index:${outboundKey.keyId} sent to ${outboundKey.sharedWith.map((m) => `${m.userId}:${m.deviceId}`).join(",")}`,
454
+ await this.transport.sendKey(
455
+ encodeBase64(newOutboundEncryptionSession.key),
456
+ newOutboundEncryptionSession.keyId,
457
+ toDistributeTo,
414
458
  );
459
+ newOutboundEncryptionSession.sharedWith.push(...toDistributeTo);
460
+ const outboundSessionList = newOutboundEncryptionSession.sharedWith
461
+ .map((m) => `${m.userId}:${m.deviceId}`)
462
+ .join(",");
463
+ this.logger?.trace(`key index:${newOutboundEncryptionSession.keyId} sent to ${outboundSessionList}`);
415
464
  if (hasKeyChanged) {
416
465
  // Delay a bit before using this key
417
466
  // It is recommended not to start using a key immediately but instead wait for a short time to make sure it is delivered.
418
- this.logger?.trace(`Delay Rollout for key:${outboundKey.keyId}...`);
467
+ this.logger?.trace(`Delay Rollout for key:${newOutboundEncryptionSession.keyId}...`);
419
468
  await sleep(this.useKeyDelay);
420
- this.logger?.trace(`...Delayed rollout of index:${outboundKey.keyId} `);
469
+ this.logger?.trace(`...Delayed rollout of index:${newOutboundEncryptionSession.keyId} `);
421
470
  this.addKeyToParticipantWithBackendIdentity(
422
- outboundKey.key,
423
- outboundKey.keyId,
471
+ newOutboundEncryptionSession.key,
472
+ newOutboundEncryptionSession.keyId,
424
473
  this.ownMembership,
425
474
  await this.getOwnRtcBackendIdentity(),
426
475
  );
@@ -158,13 +158,22 @@ export class RoomReceipts {
158
158
  }
159
159
  }
160
160
 
161
- // TODO: what if they sent the second-last event in the thread?
162
- if (this.userSentLatestEventInThread(threadId, userId)) {
163
- // The user sent the latest message in this event's thread, so we
164
- // consider everything in the thread to be read.
161
+ if (this.userSentEventAfterOrSame(threadId, userId, eventId)) {
162
+ // The user sent an event at or after this one, so they must have
163
+ // seen this one: we consider it read.
165
164
  //
166
- // Note: maybe we don't need this because synthetic receipts should
167
- // do this job for us?
165
+ // Note: the synthetic receipts we create when we see an event
166
+ // usually do this job for us, but not always - e.g. we don't create
167
+ // them for events we paginated in.
168
+ return true;
169
+ }
170
+
171
+ if (event.isThreadRoot && this.userRepliedInThread(eventId, userId)) {
172
+ // The user replied in the thread rooted at this event, and you
173
+ // can't reply to a thread without seeing its root. We have to check
174
+ // this separately because their reply is in the thread's timeline
175
+ // whereas the root is in the main one, so no receipt of theirs can
176
+ // cover the root.
168
177
  return true;
169
178
  }
170
179
 
@@ -173,17 +182,52 @@ export class RoomReceipts {
173
182
  }
174
183
 
175
184
  /**
176
- * @returns true if the thread with this ID can be found, and the supplied
177
- * user sent the latest message in it.
185
+ * @returns true if the timeline for this thread ID can be found, and the
186
+ * supplied user sent an event in it that is the same as, or after,
187
+ * the event with the supplied ID.
178
188
  */
179
- private userSentLatestEventInThread(threadId: string, userId: string): boolean {
189
+ private userSentEventAfterOrSame(threadId: string, userId: string, eventId: string): boolean {
180
190
  const timeline =
181
191
  threadId === MAIN_ROOM_TIMELINE
182
192
  ? this.room.getLiveTimeline().getEvents()
183
193
  : this.room.getThread(threadId)?.timeline;
184
194
 
185
- return !!(timeline && timeline.length > 0 && timeline[timeline.length - 1].getSender() === userId);
195
+ const latestEventId = latestEventIdSentByUser(timeline, userId);
196
+ return !!latestEventId && isAfterOrSame(latestEventId, eventId, this.room);
186
197
  }
198
+
199
+ /**
200
+ * @returns true if the thread rooted at the event with the supplied ID can
201
+ * be found, and the supplied user sent a reply in it.
202
+ */
203
+ private userRepliedInThread(rootEventId: string, userId: string): boolean {
204
+ // The root is part of the thread's timeline, but it is not a reply to
205
+ // the thread, so leave it out.
206
+ const replies = this.room.getThread(rootEventId)?.timeline.filter((event) => event.getId() !== rootEventId);
207
+ return !!latestEventIdSentByUser(replies, userId);
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Find the last event in the supplied timeline that was sent by this user.
213
+ *
214
+ * Events that are still on their way to the server (or failed to get there) are
215
+ * ignored: they have no place in the timeline yet, and a failed send tells us
216
+ * nothing about what the user has seen.
217
+ *
218
+ * @returns the ID of the found event, or undefined if there is no such event.
219
+ */
220
+ function latestEventIdSentByUser(timeline: Array<MatrixEvent> | undefined, userId: string): string | undefined {
221
+ if (!timeline) return undefined;
222
+
223
+ for (let index = timeline.length - 1; index >= 0; index--) {
224
+ const event = timeline[index];
225
+ if (event.getSender() === userId && !event.status) {
226
+ return event.getId();
227
+ }
228
+ }
229
+
230
+ return undefined;
187
231
  }
188
232
 
189
233
  // --- implementation details ---
@@ -27,6 +27,8 @@ import {
27
27
  import { Method } from "../http-api/index.ts";
28
28
  import { OAuthGrantType } from "./register.ts";
29
29
  import { sleep } from "../utils.ts";
30
+ import { type Logger, logger as rootLogger } from "../logger.ts";
31
+ import { fetchWithLogging } from "./fetch.ts";
30
32
 
31
33
  /**
32
34
  * The expected response type from the token endpoint during authorization code flow
@@ -185,6 +187,7 @@ export function validateDeviceAuthorizationResponse(
185
187
  * @param options.clientId - the client ID returned from client registration.
186
188
  * @param options.scope - the scope to request for authorization.
187
189
  * @param options.metadata - the validated OAuth2 metadata for the Identity Provider.
190
+ * @param options.logger - optional logger to use for the request, defaults to the root logger of the js-sdk.
188
191
  * @returns a promise that resolves to a device access token response,
189
192
  * or an error response if the user denies authorization or the device code expires.
190
193
  */
@@ -192,10 +195,12 @@ export const startDeviceAuthorization = async ({
192
195
  clientId,
193
196
  scope,
194
197
  metadata,
198
+ logger = rootLogger,
195
199
  }: {
196
200
  clientId: string;
197
201
  scope: string;
198
202
  metadata: ValidatedAuthMetadata;
203
+ logger?: Logger;
199
204
  }): Promise<DeviceAuthorizationResponse> => {
200
205
  const body = new URLSearchParams({ client_id: clientId, scope: scope }).toString();
201
206
 
@@ -204,7 +209,7 @@ export const startDeviceAuthorization = async ({
204
209
  throw new Error("No device_authorization_endpoint given");
205
210
  }
206
211
 
207
- const response = await fetch(url, {
212
+ const response = await fetchWithLogging(logger, url, {
208
213
  method: Method.Post,
209
214
  headers: {
210
215
  "Content-Type": "application/x-www-form-urlencoded",
@@ -223,6 +228,7 @@ export const startDeviceAuthorization = async ({
223
228
  * @param options.session - The session returned from a previous call to {@link startDeviceAuthorization}.
224
229
  * @param options.metadata - The validated OAuth2 metadata for the Identity Provider.
225
230
  * @param options.clientId - The client ID returned from client registration.
231
+ * @param options.logger - optional logger to use for the requests, defaults to the root logger of the js-sdk.
226
232
  * @returns a promise that resolves to a device access token response,
227
233
  * or an error response if the user denies authorization or the device code expires.
228
234
  */
@@ -230,10 +236,12 @@ export const waitForDeviceAuthorization = async ({
230
236
  session,
231
237
  metadata,
232
238
  clientId,
239
+ logger = rootLogger,
233
240
  }: {
234
241
  session: DeviceAuthorizationResponse;
235
242
  metadata: ValidatedAuthMetadata;
236
243
  clientId: string;
244
+ logger?: Logger;
237
245
  }): Promise<DeviceAccessTokenResponse | DeviceAccessTokenError> => {
238
246
  let interval = (session.interval ?? 5) * 1000; // poll interval
239
247
  const expiration = Date.now() + session.expires_in * 1000;
@@ -243,7 +251,7 @@ export const waitForDeviceAuthorization = async ({
243
251
  grant_type: OAuthGrantType.DeviceAuthorization,
244
252
  client_id: clientId,
245
253
  }).toString();
246
- const response = await fetch(metadata.token_endpoint, {
254
+ const response = await fetchWithLogging(logger, metadata.token_endpoint, {
247
255
  method: Method.Post,
248
256
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
249
257
  body,
@@ -0,0 +1,54 @@
1
+ /*
2
+ Copyright 2026 The Matrix.org Foundation C.I.C.
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { sanitizeUrlForLogs } from "../http-api/logging.ts";
18
+ import { Method } from "../http-api/method.ts";
19
+ import { type Logger } from "../logger.ts";
20
+
21
+ /**
22
+ * Perform a `fetch` request, logging the request and response in the same manner as
23
+ * `FetchHttpApi` does for Client-Server API requests.
24
+ *
25
+ * Neither the request nor the response body is logged, as they routinely contain credentials.
26
+ * Query parameter values are redacted for the same reason.
27
+ *
28
+ * @internal
29
+ * @param logger - the logger to write the request and response lines to.
30
+ * @param resource - the URL to request.
31
+ * @param options - the options to pass to `fetch`.
32
+ * @returns the `Response`, whatever its status code.
33
+ * @throws rethrows whatever `fetch` threw, having logged it.
34
+ */
35
+ export async function fetchWithLogging(
36
+ logger: Logger,
37
+ resource: URL | string,
38
+ options: RequestInit = {},
39
+ ): Promise<Response> {
40
+ const method = options.method ?? Method.Get;
41
+ const urlForLogs = sanitizeUrlForLogs(resource);
42
+
43
+ logger.debug(`OAuth2: --> ${method} ${urlForLogs}`);
44
+
45
+ const start = Date.now();
46
+ try {
47
+ const res = await globalThis.fetch(resource, options);
48
+ logger.debug(`OAuth2: <-- ${method} ${urlForLogs} [${Date.now() - start}ms ${res.status}]`);
49
+ return res;
50
+ } catch (e) {
51
+ logger.debug(`OAuth2: <-- ${method} ${urlForLogs} [${Date.now() - start}ms ${e}]`);
52
+ throw e;
53
+ }
54
+ }
@@ -35,7 +35,8 @@ import {
35
35
  import { encodeUnpaddedBase64Url } from "../base64.ts";
36
36
  import { sha256 } from "../digest.ts";
37
37
  import { HTTPError, isMatrixErrorResponse, MatrixError, Method } from "../http-api/index.ts";
38
- import { logger } from "../logger.ts";
38
+ import { type Logger, logger as rootLogger } from "../logger.ts";
39
+ import { fetchWithLogging } from "./fetch.ts";
39
40
  import { isOAuth2ErrorResponse, OAuth2Error, OAuth2HTTPError } from "./error.ts";
40
41
  import { secureRandomString } from "../randomstring.ts";
41
42
  import { type NonEmptyArray } from "../@types/common.ts";
@@ -67,12 +68,14 @@ export class OAuth2 {
67
68
  * @param authMetadata - Auth config from {@link MatrixClient.getAuthMetadata}
68
69
  * @param clientMetadata - The metadata for the client which to register,
69
70
  * grant_types & response_types & token_endpoint_auth_method will be sanely calculated if omitted.
71
+ * @param logger - Optional logger to use for the request, defaults to the root logger of the js-sdk.
70
72
  * @returns Promise<string> resolved with registered clientId
71
73
  * @throws when registration is not supported, on failed request or invalid response
72
74
  */
73
75
  public static async registerClient(
74
76
  authMetadata: ValidatedAuthMetadata,
75
77
  clientMetadata: OAuthRegistrationRequest,
78
+ logger: Logger = rootLogger,
76
79
  ): Promise<string> {
77
80
  const defaultGrantTypes: NonEmptyArray<string> = [
78
81
  OAuthGrantType.AuthorizationCode,
@@ -102,7 +105,7 @@ export class OAuth2 {
102
105
  };
103
106
 
104
107
  try {
105
- const response = await fetch(authMetadata.registration_endpoint, {
108
+ const response = await fetchWithLogging(logger, authMetadata.registration_endpoint, {
106
109
  method: Method.Post,
107
110
  headers: {
108
111
  "Accept": "application/json",
@@ -133,9 +136,16 @@ export class OAuth2 {
133
136
 
134
137
  public readonly context: Required<Context>;
135
138
 
139
+ /**
140
+ * @param metadata - The validated OAuth 2.0 metadata for the Identity Provider.
141
+ * @param context - The persistent context needed for the OAuth flows.
142
+ * @param logger - Optional logger to use for the requests made by this instance,
143
+ * defaults to the root logger of the js-sdk.
144
+ */
136
145
  public constructor(
137
146
  public readonly metadata: ValidatedAuthMetadata,
138
147
  context: Context,
148
+ private readonly logger: Logger = rootLogger,
139
149
  ) {
140
150
  this.context = {
141
151
  clientId: context.clientId,
@@ -200,7 +210,8 @@ export class OAuth2 {
200
210
  params.append("redirect_uri", this.context.redirectUri);
201
211
  params.append("code", code);
202
212
 
203
- const tokenResponse = await this.fetch("token", params, OAuth2Error.CodeExchangeFailed);
213
+ const res = await this.fetch("token", params, OAuth2Error.CodeExchangeFailed);
214
+ const tokenResponse = await res.json();
204
215
 
205
216
  // throws when response is invalid
206
217
  validateBearerTokenResponse(tokenResponse);
@@ -217,7 +228,8 @@ export class OAuth2 {
217
228
  params.append("client_id", this.context.clientId);
218
229
  params.append("refresh_token", refreshToken);
219
230
 
220
- const tokenResponse = await this.fetch("token", params, OAuth2Error.RefreshTokenFailed);
231
+ const res = await this.fetch("token", params, OAuth2Error.RefreshTokenFailed);
232
+ const tokenResponse = await res.json();
221
233
 
222
234
  // throws when response is invalid
223
235
  validateBearerTokenResponse(tokenResponse);
@@ -237,10 +249,11 @@ export class OAuth2 {
237
249
  params.append("token_type_hint", type);
238
250
  }
239
251
 
252
+ // [RFC 7009 section 2.2](https://www.rfc-editor.org/rfc/rfc7009.html#section-2.2) says:
253
+ // > The content of the response body is ignored by the client as all
254
+ // > necessary information is conveyed in the response code.
255
+ // so, we don't do anything with the response body.
240
256
  await this.fetch("revocation", params, OAuth2Error.RevokeTokenFailed);
241
-
242
- const headers = new Headers();
243
- headers.set("Content-Type", "application/x-www-form-urlencoded");
244
257
  }
245
258
 
246
259
  /**
@@ -254,6 +267,7 @@ export class OAuth2 {
254
267
  scope: scope ?? generateScope(this.context.deviceId),
255
268
  metadata: this.metadata,
256
269
  clientId: this.context.clientId,
270
+ logger: this.logger,
257
271
  });
258
272
  }
259
273
 
@@ -270,16 +284,21 @@ export class OAuth2 {
270
284
  session,
271
285
  metadata: this.metadata,
272
286
  clientId: this.context.clientId,
287
+ logger: this.logger,
273
288
  });
274
289
  }
275
290
 
291
+ /**
292
+ * Make a request to one of the OAuth 2.0 endpoints, throwing if it responds with an error.
293
+ * @returns the {@link Response}, for the caller to parse if the endpoint returns a body.
294
+ */
276
295
  private async fetch(
277
296
  target: "token" | "registration" | "revocation",
278
297
  params: URLSearchParams,
279
298
  error: OAuth2Error,
280
- ): Promise<unknown> {
299
+ ): Promise<Response> {
281
300
  const url = this.metadata[`${target}_endpoint`];
282
- const res = await fetch(url, {
301
+ const res = await fetchWithLogging(this.logger, url, {
283
302
  method: Method.Post,
284
303
  headers: {
285
304
  "Content-Type": "application/x-www-form-urlencoded",
@@ -309,6 +328,6 @@ export class OAuth2 {
309
328
  throw new HTTPError(error, res.status, res.headers);
310
329
  }
311
330
 
312
- return await res.json();
331
+ return res;
313
332
  }
314
333
  }