matrix-js-sdk 41.4.0 → 41.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -0
  3. package/lib/@types/json.d.ts +7 -0
  4. package/lib/@types/json.d.ts.map +1 -0
  5. package/lib/@types/json.js +1 -0
  6. package/lib/@types/json.js.map +1 -0
  7. package/lib/@types/requests.d.ts +6 -9
  8. package/lib/@types/requests.d.ts.map +1 -1
  9. package/lib/@types/requests.js.map +1 -1
  10. package/lib/client.d.ts +17 -2
  11. package/lib/client.d.ts.map +1 -1
  12. package/lib/client.js +27 -12
  13. package/lib/client.js.map +1 -1
  14. package/lib/filter.d.ts +20 -5
  15. package/lib/filter.d.ts.map +1 -1
  16. package/lib/filter.js +21 -0
  17. package/lib/filter.js.map +1 -1
  18. package/lib/models/user.d.ts +5 -0
  19. package/lib/models/user.d.ts.map +1 -1
  20. package/lib/models/user.js +5 -0
  21. package/lib/models/user.js.map +1 -1
  22. package/lib/oidc/authorize.d.ts +60 -0
  23. package/lib/oidc/authorize.d.ts.map +1 -1
  24. package/lib/oidc/authorize.js +115 -2
  25. package/lib/oidc/authorize.js.map +1 -1
  26. package/lib/oidc/register.d.ts.map +1 -1
  27. package/lib/oidc/register.js +5 -0
  28. package/lib/oidc/register.js.map +1 -1
  29. package/lib/rendezvous/MSC4108SignInWithQR.d.ts +19 -2
  30. package/lib/rendezvous/MSC4108SignInWithQR.d.ts.map +1 -1
  31. package/lib/rendezvous/MSC4108SignInWithQR.js +126 -36
  32. package/lib/rendezvous/MSC4108SignInWithQR.js.map +1 -1
  33. package/lib/rendezvous/channels/MSC4108SecureChannel.d.ts.map +1 -1
  34. package/lib/rendezvous/channels/MSC4108SecureChannel.js +4 -2
  35. package/lib/rendezvous/channels/MSC4108SecureChannel.js.map +1 -1
  36. package/lib/rendezvous/index.d.ts +36 -0
  37. package/lib/rendezvous/index.d.ts.map +1 -1
  38. package/lib/rendezvous/index.js +115 -0
  39. package/lib/rendezvous/index.js.map +1 -1
  40. package/lib/rendezvous/transports/MSC4108RendezvousSession.d.ts +1 -1
  41. package/lib/rendezvous/transports/MSC4108RendezvousSession.d.ts.map +1 -1
  42. package/lib/rendezvous/transports/MSC4108RendezvousSession.js.map +1 -1
  43. package/lib/rust-crypto/rust-crypto.d.ts.map +1 -1
  44. package/lib/rust-crypto/rust-crypto.js +2 -2
  45. package/lib/rust-crypto/rust-crypto.js.map +1 -1
  46. package/lib/store/index.d.ts +17 -1
  47. package/lib/store/index.d.ts.map +1 -1
  48. package/lib/store/index.js.map +1 -1
  49. package/lib/store/indexeddb-backend.d.ts +4 -0
  50. package/lib/store/indexeddb-backend.d.ts.map +1 -1
  51. package/lib/store/indexeddb-backend.js.map +1 -1
  52. package/lib/store/indexeddb-local-backend.d.ts +4 -1
  53. package/lib/store/indexeddb-local-backend.d.ts.map +1 -1
  54. package/lib/store/indexeddb-local-backend.js +45 -3
  55. package/lib/store/indexeddb-local-backend.js.map +1 -1
  56. package/lib/store/indexeddb-remote-backend.d.ts +4 -0
  57. package/lib/store/indexeddb-remote-backend.d.ts.map +1 -1
  58. package/lib/store/indexeddb-remote-backend.js +21 -3
  59. package/lib/store/indexeddb-remote-backend.js.map +1 -1
  60. package/lib/store/indexeddb-store-worker.d.ts.map +1 -1
  61. package/lib/store/indexeddb-store-worker.js +10 -1
  62. package/lib/store/indexeddb-store-worker.js.map +1 -1
  63. package/lib/store/indexeddb.d.ts +4 -0
  64. package/lib/store/indexeddb.d.ts.map +1 -1
  65. package/lib/store/indexeddb.js +18 -0
  66. package/lib/store/indexeddb.js.map +1 -1
  67. package/lib/store/memory.d.ts +5 -1
  68. package/lib/store/memory.d.ts.map +1 -1
  69. package/lib/store/memory.js +19 -0
  70. package/lib/store/memory.js.map +1 -1
  71. package/lib/store/stub.d.ts +3 -0
  72. package/lib/store/stub.d.ts.map +1 -1
  73. package/lib/store/stub.js +15 -0
  74. package/lib/store/stub.js.map +1 -1
  75. package/lib/sync-accumulator.d.ts +15 -0
  76. package/lib/sync-accumulator.d.ts.map +1 -1
  77. package/lib/sync-accumulator.js +4 -0
  78. package/lib/sync-accumulator.js.map +1 -1
  79. package/lib/sync.d.ts +9 -1
  80. package/lib/sync.d.ts.map +1 -1
  81. package/lib/sync.js +51 -9
  82. package/lib/sync.js.map +1 -1
  83. package/lib/webrtc/call.d.ts.map +1 -1
  84. package/lib/webrtc/call.js +1 -2
  85. package/lib/webrtc/call.js.map +1 -1
  86. package/package.json +7 -7
  87. package/src/@types/json.ts +16 -0
  88. package/src/@types/requests.ts +6 -9
  89. package/src/client.ts +40 -12
  90. package/src/filter.ts +31 -5
  91. package/src/models/user.ts +6 -0
  92. package/src/oidc/authorize.ts +135 -2
  93. package/src/oidc/register.ts +5 -0
  94. package/src/rendezvous/MSC4108SignInWithQR.ts +117 -4
  95. package/src/rendezvous/channels/MSC4108SecureChannel.ts +10 -2
  96. package/src/rendezvous/index.ts +115 -0
  97. package/src/rendezvous/transports/MSC4108RendezvousSession.ts +1 -1
  98. package/src/rust-crypto/rust-crypto.ts +6 -3
  99. package/src/store/index.ts +20 -1
  100. package/src/store/indexeddb-backend.ts +4 -0
  101. package/src/store/indexeddb-local-backend.ts +32 -1
  102. package/src/store/indexeddb-remote-backend.ts +13 -0
  103. package/src/store/indexeddb-store-worker.ts +9 -0
  104. package/src/store/indexeddb.ts +13 -0
  105. package/src/store/memory.ts +14 -1
  106. package/src/store/stub.ts +12 -0
  107. package/src/sync-accumulator.ts +16 -1
  108. package/src/sync.ts +48 -4
  109. package/src/webrtc/call.ts +1 -2
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "matrix-js-sdk",
3
- "version": "41.4.0",
3
+ "version": "41.5.0",
4
4
  "description": "Matrix Client-Server SDK for Javascript",
5
5
  "engines": {
6
6
  "node": ">=22.0.0"
7
7
  },
8
8
  "scripts": {
9
9
  "prepare": "pnpm build",
10
- "start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && babel --delete-dir-on-start src -w -s -d lib --verbose --extensions \".ts,.js\"",
10
+ "start": "concurrently 'pnpm run build:compile --watch' 'pnpm run build:types --watch'",
11
11
  "build": "pnpm build:compile && pnpm build:types",
12
12
  "build:types": "tsc -p tsconfig-build.json --emitDeclarationOnly",
13
13
  "build:compile": "babel --delete-dir-on-start -d lib --verbose --extensions \".ts,.js\" src",
@@ -48,7 +48,7 @@
48
48
  ],
49
49
  "dependencies": {
50
50
  "@babel/runtime": "^7.12.5",
51
- "@matrix-org/matrix-sdk-crypto-wasm": "^18.1.0",
51
+ "@matrix-org/matrix-sdk-crypto-wasm": "^18.2.0",
52
52
  "another-json": "^0.2.0",
53
53
  "bs58": "^6.0.0",
54
54
  "content-type": "^1.0.4",
@@ -59,8 +59,7 @@
59
59
  "oidc-client-ts": "^3.0.1",
60
60
  "p-retry": "8",
61
61
  "sdp-transform": "^3.0.0",
62
- "unhomoglyph": "^1.0.6",
63
- "uuid": "13"
62
+ "unhomoglyph": "^1.0.6"
64
63
  },
65
64
  "devDependencies": {
66
65
  "@action-validator/cli": "^0.6.0",
@@ -91,6 +90,7 @@
91
90
  "@vitest/eslint-plugin": "^1.6.6",
92
91
  "@vitest/ui": "^4.0.17",
93
92
  "babel-plugin-search-and-replace": "^1.1.1",
93
+ "concurrently": "^9.2.1",
94
94
  "debug": "^4.3.4",
95
95
  "eslint": "8.57.1",
96
96
  "eslint-config-google": "^0.14.0",
@@ -132,8 +132,8 @@
132
132
  "flatted@<=3.4.1": "^3.4.2",
133
133
  "picomatch@>=4.0.0 <4.0.4": "^4.0.4",
134
134
  "yaml@>=2.0.0 <2.8.3": "^2.8.3",
135
- "vite": "7.3.2"
135
+ "vite": "8.0.8"
136
136
  }
137
137
  },
138
- "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
138
+ "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8"
139
139
  }
@@ -0,0 +1,16 @@
1
+ /*
2
+ Copyright 2024 New Vector Ltd.
3
+
4
+ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5
+ Please see LICENSE files in the repository root for full details.
6
+ */
7
+
8
+ // Types for JSON and JSON objects, copied from element-web (left in both places as I don't think we
9
+ // want this as a part of js-sdk's interface and I don't think it's worth it being its own package)
10
+
11
+ export type JsonValue = null | string | number | boolean;
12
+ export type JsonArray = Array<JsonValue | JsonObject | JsonArray>;
13
+ export interface JsonObject {
14
+ [key: string]: JsonObject | JsonArray | JsonValue;
15
+ }
16
+ export type Json = JsonArray | JsonObject;
@@ -40,10 +40,10 @@ export interface IJoinRoomOpts {
40
40
  viaServers?: string[];
41
41
 
42
42
  /**
43
- * When accepting an invite, whether to accept encrypted history shared by the inviter via the experimental
44
- * support for [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268).
43
+ * Previously, configured whether to accept encrypted history shared by the inviter. This is now always enabled,
44
+ * and the setting is only retained to avoid a breaking change to the API. It has no effect.
45
45
  *
46
- * @experimental
46
+ * @deprecated
47
47
  */
48
48
  acceptSharedHistory?: boolean;
49
49
  }
@@ -56,13 +56,10 @@ export interface InviteOpts {
56
56
  reason?: string;
57
57
 
58
58
  /**
59
- * Before sending the invite, if the room is encrypted, share the keys for any messages sent while the history
60
- * visibility was `shared`, via the experimental
61
- * support for [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268). If the room's current
62
- * history visibility setting is neither `shared` nor `world_readable`, history sharing will be disabled to prevent
63
- * exposing keys for messages sent prior to the visibility restriction.
59
+ * Previously, configured whether to send encrypted history if the visibility settings allow it.
60
+ * This is now always enabled, and the setting is only retained to avoid a breaking change to the API. It has no effect.
64
61
  *
65
- * @experimental
62
+ * @deprecated
66
63
  */
67
64
  shareEncryptedHistory?: boolean;
68
65
  }
package/src/client.ts CHANGED
@@ -78,7 +78,7 @@ import {
78
78
  type UploadOpts,
79
79
  type UploadResponse,
80
80
  } from "./http-api/index.ts";
81
- import { User, UserEvent, type UserEventHandlerMap } from "./models/user.ts";
81
+ import { type SyncUserProfile, User, UserEvent, type UserEventHandlerMap } from "./models/user.ts";
82
82
  import { getHttpUriForMxc } from "./content-repo.ts";
83
83
  import { SearchResult } from "./models/search-result.ts";
84
84
  import { type IIdentityServerProvider } from "./@types/IIdentityServerProvider.ts";
@@ -539,6 +539,12 @@ export interface IStartClientOpts {
539
539
  * @experimental
540
540
  */
541
541
  slidingSync?: SlidingSync;
542
+
543
+ /**
544
+ * Include user profiles in sync responses.
545
+ * Will only work if the server supports MSC4429.
546
+ */
547
+ unstableMSC4429SyncUserProfileFields?: string[];
542
548
  }
543
549
 
544
550
  export interface IStoredClientOpts extends IStartClientOpts {}
@@ -1102,6 +1108,7 @@ export enum ClientEvent {
1102
1108
  ReceivedVoipEvent = "received_voip_event",
1103
1109
  TurnServers = "turnServers",
1104
1110
  TurnServersError = "turnServers.error",
1111
+ UserProfileUpdate = "userProfileUpdate",
1105
1112
  }
1106
1113
 
1107
1114
  type RoomEvents =
@@ -1172,6 +1179,12 @@ export type ClientEventHandlerMap = {
1172
1179
  [ClientEvent.ReceivedVoipEvent]: (event: MatrixEvent) => void;
1173
1180
  [ClientEvent.TurnServers]: (servers: ITurnServer[]) => void;
1174
1181
  [ClientEvent.TurnServersError]: (error: Error, fatal: boolean) => void;
1182
+ /**
1183
+ *
1184
+ * @param userId - the user ID of the profile which was updated
1185
+ * @param profile - the updated profile information
1186
+ */
1187
+ [ClientEvent.UserProfileUpdate]: (userId: string, profile: Record<string, unknown> | null) => void;
1175
1188
  } & RoomEventHandlerMap &
1176
1189
  RoomStateEventHandlerMap &
1177
1190
  CryptoEventHandlerMap &
@@ -2428,7 +2441,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
2428
2441
  const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryParams, data);
2429
2442
 
2430
2443
  const roomId = res.room_id;
2431
- if (opts.acceptSharedHistory && inviter && this.cryptoBackend) {
2444
+ if (inviter && this.cryptoBackend) {
2432
2445
  // Flag upfront that we are waiting for a key bundle, so that if we crash mid-import, we can try again.
2433
2446
  await this.cryptoBackend.markRoomAsPendingKeyBundle(roomId, inviter);
2434
2447
  // Try to accept the room key bundle specified in a `m.room_key_bundle` to-device message we (might have) already received.
@@ -4086,14 +4099,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
4086
4099
  opts = { reason: opts };
4087
4100
  }
4088
4101
 
4089
- if (opts.shareEncryptedHistory) {
4090
- const historyVisibility = this.getRoom(roomId)?.getHistoryVisibility() ?? HistoryVisibility.Shared;
4091
- // We should only share room history if the *current* visibility allows it.
4092
- if ([HistoryVisibility.Invited, HistoryVisibility.Joined].includes(historyVisibility)) {
4093
- this.logger.debug("Not sharing message history as the room history visibility is currently unshared");
4094
- } else {
4095
- await this.cryptoBackend?.shareRoomHistoryWithUser(roomId, userId);
4096
- }
4102
+ const historyVisibility = this.getRoom(roomId)?.getHistoryVisibility() ?? HistoryVisibility.Shared;
4103
+ // We should only share room history if the *current* visibility allows it.
4104
+ if ([HistoryVisibility.Invited, HistoryVisibility.Joined].includes(historyVisibility)) {
4105
+ this.logger.debug("Not sharing message history as the room history visibility is currently unshared");
4106
+ } else {
4107
+ await this.cryptoBackend?.shareRoomHistoryWithUser(roomId, userId);
4097
4108
  }
4098
4109
 
4099
4110
  return await this.membershipChange(roomId, userId, KnownMembership.Invite, opts.reason);
@@ -7364,6 +7375,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
7364
7375
 
7365
7376
  /**
7366
7377
  * Fetch a user's *extended* profile, which may include additional keys.
7378
+ * Always returns all available profile fields, irrespective of what profile fields are set
7379
+ * in the sync filter.
7367
7380
  *
7368
7381
  * @see https://github.com/tcpipuk/matrix-spec-proposals/blob/main/proposals/4133-extended-profiles.md
7369
7382
  * @param userId The user ID to fetch the profile of.
@@ -7376,6 +7389,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
7376
7389
  if (!(await this.doesServerSupportExtendedProfiles())) {
7377
7390
  throw new Error("Server does not support extended profiles");
7378
7391
  }
7392
+
7393
+ // Note that this does not look at the profile cache as this will only contain keys
7394
+ // that we included in the sync filter and this function's purpose is to return the whole profile.
7395
+
7379
7396
  return this.http.authedRequest(
7380
7397
  Method.Get,
7381
7398
  utils.encodeUri("/profile/$userId", { $userId: userId }),
@@ -7388,7 +7405,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
7388
7405
  }
7389
7406
 
7390
7407
  /**
7391
- * Fetch a specific key from the user's *extended* profile.
7408
+ * Fetch a specific key from the user's *extended* profile by checking local cache (which is updated from
7409
+ * the sync) and querying the server if no data is cached locally.
7392
7410
  *
7393
7411
  * @see https://github.com/tcpipuk/matrix-spec-proposals/blob/main/proposals/4133-extended-profiles.md
7394
7412
  * @param userId The user ID to fetch the profile of.
@@ -7402,6 +7420,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
7402
7420
  if (!(await this.doesServerSupportExtendedProfiles())) {
7403
7421
  throw new Error("Server does not support extended profiles");
7404
7422
  }
7423
+ // NOTE: We only read individual keys from a cached profile as we don't have the full profile
7424
+ // cached, only the keys that the user has configured via their sync filter.
7425
+ const storedProfile = await this.store.getUserProfile(userId);
7426
+ if (storedProfile?.[key] !== undefined) {
7427
+ return storedProfile[key];
7428
+ }
7405
7429
  const profile = (await this.http.authedRequest(
7406
7430
  Method.Get,
7407
7431
  utils.encodeUri("/profile/$userId/$key", { $userId: userId, $key: key }),
@@ -7410,7 +7434,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
7410
7434
  {
7411
7435
  prefix: await this.getExtendedProfileRequestPrefix(),
7412
7436
  },
7413
- )) as Record<string, unknown>;
7437
+ )) as SyncUserProfile;
7438
+
7439
+ // write through to the cache
7440
+ await this.store.storeUserProfiles(new Map([[userId, profile]]));
7441
+
7414
7442
  return profile[key];
7415
7443
  }
7416
7444
 
package/src/filter.ts CHANGED
@@ -18,6 +18,9 @@ import { type EventType, type RelationType } from "./@types/event.ts";
18
18
  import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync.ts";
19
19
  import { FilterComponent, type IFilterComponent } from "./filter-component.ts";
20
20
  import { type MatrixEvent } from "./models/event.ts";
21
+ import { NamespacedValue } from "./NamespacedValue.ts";
22
+
23
+ const profileFieldsFilterName = new NamespacedValue("profile_fields", "org.matrix.msc4429.profile_fields");
21
24
 
22
25
  /**
23
26
  */
@@ -35,11 +38,13 @@ function setProp(obj: Record<string, any>, keyNesting: string, val: any): void {
35
38
 
36
39
  /* eslint-disable camelcase */
37
40
  export interface IFilterDefinition {
38
- event_fields?: string[];
39
- event_format?: "client" | "federation";
40
- presence?: IFilterComponent;
41
- account_data?: IFilterComponent;
42
- room?: IRoomFilter;
41
+ "event_fields"?: string[];
42
+ "event_format"?: "client" | "federation";
43
+ "presence"?: IFilterComponent;
44
+ "account_data"?: IFilterComponent;
45
+ "room"?: IRoomFilter;
46
+ "profile_fields"?: ProfileFieldsFilter;
47
+ "org.matrix.msc4429.profile_fields"?: ProfileFieldsFilter;
43
48
  }
44
49
 
45
50
  export interface IRoomEventFilter extends IFilterComponent {
@@ -67,6 +72,14 @@ interface IRoomFilter {
67
72
  timeline?: IRoomEventFilter;
68
73
  account_data?: IRoomEventFilter;
69
74
  }
75
+
76
+ /**
77
+ * Filter section used for requesting a set of extended profile fields that will be sent down the sync stream.
78
+ */
79
+ interface ProfileFieldsFilter {
80
+ ids: string[];
81
+ }
82
+
70
83
  /* eslint-enable camelcase */
71
84
 
72
85
  export class Filter {
@@ -242,4 +255,17 @@ export class Filter {
242
255
  public setIncludeLeaveRooms(includeLeave: boolean): void {
243
256
  setProp(this.definition, "room.include_leave", includeLeave);
244
257
  }
258
+
259
+ /**
260
+ * Set the list of fields to be included in the profile information sent down the sync stream.
261
+ * @param ids The field IDs to sync.
262
+ * @param stable Whether to use the stable or unstable versions of this filter.
263
+ * @experimental
264
+ */
265
+ public setUnstableMSC4429SyncUserProfiles(ids: string[], stable: boolean): void {
266
+ const field = stable
267
+ ? profileFieldsFilterName.name
268
+ : (profileFieldsFilterName.unstable ?? profileFieldsFilterName.name);
269
+ this.definition[field] = { ids };
270
+ }
245
271
  }
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */
16
16
 
17
+ import { type Json, type JsonValue } from "../@types/json.ts";
17
18
  import { type MatrixClient } from "../matrix.ts";
18
19
  import { type MatrixEvent } from "./event.ts";
19
20
  import { TypedEventEmitter } from "./typed-event-emitter.ts";
@@ -26,6 +27,11 @@ export enum UserEvent {
26
27
  LastPresenceTs = "User.lastPresenceTs",
27
28
  }
28
29
 
30
+ /**
31
+ * An object of extended profile attributes for a user as it arrives down the sync stream.
32
+ */
33
+ export type SyncUserProfile = Record<string, Json | JsonValue>;
34
+
29
35
  export type UserEventHandlerMap = {
30
36
  /**
31
37
  * Fires whenever any user's display name changes.
@@ -37,6 +37,9 @@ import {
37
37
  } from "./validate.ts";
38
38
  import { sha256 } from "../digest.ts";
39
39
  import { encodeUnpaddedBase64Url } from "../base64.ts";
40
+ import { OAuthGrantType } from "./register.ts";
41
+ import { sleep } from "../utils.ts";
42
+ import { Method } from "../http-api/index.ts";
40
43
 
41
44
  // reexport for backwards compatibility
42
45
  export type { BearerTokenResponse };
@@ -271,8 +274,16 @@ export const completeAuthorizationCodeGrant = async (
271
274
 
272
275
  // throws when response is invalid
273
276
  validateBearerTokenResponse(signinResponse);
274
- // throws when token is invalid
275
- validateIdToken(signinResponse.id_token, client.settings.authority, client.settings.client_id, userState.nonce);
277
+ if (signinResponse.id_token) {
278
+ // The token is not yet in the Matrix spec so consider it optional
279
+ // throws when token is invalid
280
+ validateIdToken(
281
+ signinResponse.id_token,
282
+ client.settings.authority,
283
+ client.settings.client_id,
284
+ userState.nonce,
285
+ );
286
+ }
276
287
  const normalizedTokenResponse = normalizeBearerTokenResponseTokenType(signinResponse);
277
288
 
278
289
  return {
@@ -296,3 +307,125 @@ export const completeAuthorizationCodeGrant = async (
296
307
  throw new Error(OidcError.CodeExchangeFailed);
297
308
  }
298
309
  };
310
+
311
+ /**
312
+ * Response from the OIDC token endpoint when exchanging a token for grant_type device_code.
313
+ */
314
+ export interface DeviceAccessTokenResponse {
315
+ id_token?: string;
316
+ access_token: string;
317
+ token_type: string;
318
+ refresh_token?: string;
319
+ scope?: string;
320
+ expires_in?: number;
321
+ session_state?: string;
322
+ }
323
+
324
+ /**
325
+ * Error from the OIDC token endpoint when exchanging a token for grant_type device_code.
326
+ */
327
+ export interface DeviceAccessTokenError {
328
+ error: string;
329
+ error_description?: string;
330
+ error_uri?: string;
331
+ session_state?: string;
332
+ }
333
+
334
+ /**
335
+ * Response from the OIDC device authorization endpoint.
336
+ */
337
+ export interface DeviceAuthorizationResponse {
338
+ device_code: string;
339
+ user_code: string;
340
+ verification_uri: string;
341
+ verification_uri_complete?: string;
342
+ expires_in: number;
343
+ interval?: number;
344
+ }
345
+
346
+ /**
347
+ * Begin OIDC device authorization flow.
348
+ * @param options - The device authorization parameters.
349
+ * @param options.clientId - the client ID returned from client registration.
350
+ * @param options.scope - the scope to request for authorization.
351
+ * @param options.metadata - the validated OIDC metadata for the Identity Provider.
352
+ * @returns a promise that resolves to a device access token response,
353
+ * or an error response if the user denies authorization or the device code expires.
354
+ */
355
+ export const startDeviceAuthorization = async ({
356
+ clientId,
357
+ scope,
358
+ metadata,
359
+ }: {
360
+ clientId: string;
361
+ scope: string;
362
+ metadata: ValidatedAuthMetadata;
363
+ }): Promise<DeviceAuthorizationResponse> => {
364
+ const body = new URLSearchParams({ client_id: clientId, scope: scope }).toString();
365
+
366
+ const url = metadata.device_authorization_endpoint;
367
+ if (!url) {
368
+ throw new Error("No device_authorization_endpoint given");
369
+ }
370
+
371
+ const response = await fetch(url, {
372
+ method: Method.Post,
373
+ headers: {
374
+ "Content-Type": "application/x-www-form-urlencoded",
375
+ },
376
+ body,
377
+ });
378
+
379
+ return (await response.json()) as DeviceAuthorizationResponse;
380
+ };
381
+
382
+ /**
383
+ * Polls the OIDC token endpoint until we get a device access token response, or encounter an unrecoverable error.
384
+ * @param options - The device authorization parameters.
385
+ * @param options.session - The session returned from a previous call to {@link startDeviceAuthorization}.
386
+ * @param options.metadata - The validated OIDC metadata for the Identity Provider.
387
+ * @param options.clientId - The client ID returned from client registration.
388
+ * @returns a promise that resolves to a device access token response,
389
+ * or an error response if the user denies authorization or the device code expires.
390
+ */
391
+ export const waitForDeviceAuthorization = async ({
392
+ session,
393
+ metadata,
394
+ clientId,
395
+ }: {
396
+ session: DeviceAuthorizationResponse;
397
+ metadata: ValidatedAuthMetadata;
398
+ clientId: string;
399
+ }): Promise<DeviceAccessTokenResponse | DeviceAccessTokenError> => {
400
+ let interval = (session.interval ?? 5) * 1000; // poll interval
401
+ const expiration = Date.now() + session.expires_in * 1000;
402
+ do {
403
+ const body = new URLSearchParams({
404
+ device_code: session.device_code,
405
+ grant_type: OAuthGrantType.DeviceAuthorization,
406
+ client_id: clientId,
407
+ }).toString();
408
+ const response = await fetch(metadata.token_endpoint, {
409
+ method: Method.Post,
410
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
411
+ body,
412
+ });
413
+
414
+ if (response.ok) {
415
+ return (await response.json()) as DeviceAccessTokenResponse;
416
+ }
417
+ const errorResponse = (await response.json()) as DeviceAccessTokenError;
418
+ switch (errorResponse.error) {
419
+ case "authorization_pending":
420
+ break;
421
+ case "slow_down":
422
+ interval += 5000;
423
+ break;
424
+ case "access_denied":
425
+ case "expired_token":
426
+ return errorResponse;
427
+ }
428
+ await sleep(interval);
429
+ } while (Date.now() < expiration);
430
+ return { error: "expired" };
431
+ };
@@ -111,6 +111,11 @@ export const registerOidcClient = async (
111
111
  throw new Error(OidcError.DynamicRegistrationNotSupported);
112
112
  }
113
113
 
114
+ // ask for device authorization grant if supported
115
+ if (delegatedAuthConfig.grant_types_supported.includes(OAuthGrantType.DeviceAuthorization)) {
116
+ grantTypes.push(OAuthGrantType.DeviceAuthorization);
117
+ }
118
+
114
119
  const commonBase = new URL(clientMetadata.clientUri);
115
120
 
116
121
  // https://openid.net/specs/openid-connect-registration-1_0.html
@@ -27,7 +27,16 @@ import { logger } from "../logger.ts";
27
27
  import { type MSC4108SecureChannel } from "./channels/MSC4108SecureChannel.ts";
28
28
  import { MatrixError } from "../http-api/index.ts";
29
29
  import { sleep } from "../utils.ts";
30
- import { OAuthGrantType, type OidcClientConfig } from "../oidc/index.ts";
30
+ import {
31
+ type DeviceAccessTokenResponse,
32
+ type DeviceAuthorizationResponse,
33
+ generateScope,
34
+ OAuthGrantType,
35
+ startDeviceAuthorization,
36
+ type ValidatedAuthMetadata,
37
+ waitForDeviceAuthorization,
38
+ type OidcClientConfig,
39
+ } from "../oidc/index.ts";
31
40
  import { type CryptoApi } from "../crypto-api/index.ts";
32
41
 
33
42
  /**
@@ -111,6 +120,8 @@ export class MSC4108SignInWithQR {
111
120
  private readonly ourIntent: QrCodeIntent;
112
121
  private _code?: Uint8Array;
113
122
  private expectingNewDeviceId?: string;
123
+ private metadata?: ValidatedAuthMetadata;
124
+ private grantInProgress?: DeviceAuthorizationResponse;
114
125
 
115
126
  /**
116
127
  * Returns the check code for the secure channel or undefined if not generated yet.
@@ -241,14 +252,53 @@ export class MSC4108SignInWithQR {
241
252
  /**
242
253
  * The second & third step in the OIDC QR login process.
243
254
  * To be called after `negotiateProtocols` for the existing device.
244
- * To be called after OIDC negotiation for the new device. (Currently unsupported)
255
+ * To be called after OIDC negotiation for the new device.
256
+ *
257
+ * @param input - Required for the new device to start the device authorization grant, not required for the existing device reciprocating the login
245
258
  */
246
- public async deviceAuthorizationGrant(): Promise<{
259
+ public async deviceAuthorizationGrant(input?: {
260
+ metadata: ValidatedAuthMetadata;
261
+ clientId: string;
262
+ deviceId: string;
263
+ }): Promise<{
247
264
  verificationUri?: string;
248
265
  userCode?: string;
249
266
  }> {
250
267
  if (this.isNewDevice) {
251
- throw new Error("New device flows around OIDC are not yet implemented");
268
+ if (!input) {
269
+ throw new Error("Input must be provided for new device");
270
+ }
271
+
272
+ const { metadata, clientId, deviceId } = input;
273
+
274
+ const scope = generateScope(deviceId);
275
+
276
+ // MSC4108-Flow: NewDevice - start device authorization grant
277
+ const dagResponse = await startDeviceAuthorization({
278
+ clientId,
279
+ scope,
280
+ metadata,
281
+ });
282
+
283
+ this.metadata = metadata;
284
+ this.grantInProgress = dagResponse;
285
+
286
+ const protocol: DeviceAuthorizationGrantProtocolPayload = {
287
+ type: PayloadType.Protocol,
288
+ protocol: "device_authorization_grant",
289
+ device_id: deviceId,
290
+ device_authorization_grant: {
291
+ verification_uri: dagResponse.verification_uri,
292
+ verification_uri_complete: dagResponse.verification_uri_complete,
293
+ },
294
+ };
295
+
296
+ await this.send(protocol);
297
+
298
+ return {
299
+ verificationUri: dagResponse.verification_uri_complete ?? dagResponse.verification_uri,
300
+ userCode: dagResponse.user_code,
301
+ };
252
302
  } else {
253
303
  // The user needs to do step 7 for the out-of-band confirmation
254
304
  // but, first we receive the protocol chosen by the other device so that
@@ -311,6 +361,69 @@ export class MSC4108SignInWithQR {
311
361
  }
312
362
  }
313
363
 
364
+ /**
365
+ * The fourth step in the OIDC QR login process.
366
+ * The reciprocating device must perform step 5 for this method to resolve.
367
+ * To be called after {@link deviceAuthorizationGrant} only on the new device.
368
+ */
369
+ public async completeLoginOnNewDevice({
370
+ clientId,
371
+ }: {
372
+ clientId: string;
373
+ }): Promise<DeviceAccessTokenResponse | undefined> {
374
+ if (!this.isNewDevice || !this.grantInProgress || !this.metadata) {
375
+ throw new Error("Can only complete login on new device");
376
+ }
377
+
378
+ logger.info("Waiting for protocol accepted message");
379
+ // wait for accepted message
380
+ const payload = await this.receive<AcceptedPayload | FailurePayload>();
381
+
382
+ if (!payload) {
383
+ throw new RendezvousError(
384
+ "No response from existing device",
385
+ MSC4108FailureReason.UnexpectedMessageReceived,
386
+ );
387
+ }
388
+ if (payload.type === PayloadType.Failure) {
389
+ throw new RendezvousError("Failed", (payload as FailurePayload).reason);
390
+ }
391
+ if (payload.type !== PayloadType.ProtocolAccepted) {
392
+ throw new RendezvousError("Unexpected message received", MSC4108FailureReason.UnexpectedMessageReceived);
393
+ }
394
+
395
+ // poll for DAG
396
+ const res = await waitForDeviceAuthorization({
397
+ session: this.grantInProgress,
398
+ metadata: this.metadata,
399
+ clientId,
400
+ });
401
+
402
+ if (!res) {
403
+ throw new RendezvousError(
404
+ "No response from device authorization endpoint",
405
+ ClientRendezvousFailureReason.Unknown,
406
+ );
407
+ }
408
+
409
+ if ("error" in res) {
410
+ let reason: MSC4108FailureReason = MSC4108FailureReason.UnexpectedMessageReceived;
411
+ if (res.error === "expired_token") {
412
+ reason = MSC4108FailureReason.AuthorizationExpired;
413
+ } else if (res.error === "access_denied") {
414
+ reason = MSC4108FailureReason.UserCancelled;
415
+ }
416
+ const payload: FailurePayload = {
417
+ type: PayloadType.Failure,
418
+ reason,
419
+ };
420
+ await this.send(payload);
421
+ return undefined;
422
+ }
423
+
424
+ return res;
425
+ }
426
+
314
427
  /**
315
428
  * The fifth (and final) step in the OIDC QR login process.
316
429
  * To be called after the new device has completed authentication.
@@ -164,7 +164,10 @@ export class MSC4108SecureChannel {
164
164
  logger.info("Waiting for LoginInitiateMessage");
165
165
  const loginInitiateMessage = await this.rendezvousSession.receive();
166
166
  if (!loginInitiateMessage) {
167
- throw new Error("No response from other device");
167
+ throw new RendezvousError(
168
+ "No response from other device",
169
+ MSC4108FailureReason.UnexpectedMessageReceived,
170
+ );
168
171
  }
169
172
 
170
173
  const { channel, message: candidateLoginInitiateMessage } =
@@ -257,7 +260,12 @@ export class MSC4108SecureChannel {
257
260
  await this.rendezvousSession.cancel(reason);
258
261
  this.onFailure?.(reason);
259
262
  } finally {
260
- await this.close();
263
+ if (
264
+ reason !== ClientRendezvousFailureReason.UserDeclined &&
265
+ reason !== MSC4108FailureReason.UserCancelled
266
+ ) {
267
+ await this.close();
268
+ }
261
269
  }
262
270
  }
263
271