matrix-nodejs-homeserver 0.3.1 → 0.3.2

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.
package/dist/core/json.js CHANGED
@@ -1,4 +1,13 @@
1
1
  //#region src/core/json.ts
2
+ function byCodePoint(a, b) {
3
+ for (let i = 0; i < a.length && i < b.length;) {
4
+ const ca = a.codePointAt(i);
5
+ const cb = b.codePointAt(i);
6
+ if (ca !== cb) return ca - cb;
7
+ i += ca > 65535 ? 2 : 1;
8
+ }
9
+ return a.length - b.length;
10
+ }
2
11
  /** Matrix canonical JSON: sorted keys, no whitespace, UTF-8, integers only. */
3
12
  function canonicalJson(value) {
4
13
  if (value === null || typeof value !== "object") {
@@ -7,7 +16,7 @@ function canonicalJson(value) {
7
16
  }
8
17
  if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
9
18
  const obj = value;
10
- return `{${Object.keys(obj).filter((k) => obj[k] !== void 0).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
19
+ return `{${Object.keys(obj).filter((k) => obj[k] !== void 0).sort(byCodePoint).map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
11
20
  }
12
21
  function isObject(value) {
13
22
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -1,3 +1,4 @@
1
+ import { Row } from "../storage/types.js";
1
2
  import { Homeserver } from "../homeserver.js";
2
3
  //#region src/core/keys.d.ts
3
4
  /** End-to-end encryption key storage (device keys, one-time keys, cross-signing). */
@@ -28,7 +29,15 @@ export declare class KeyService {
28
29
  uploadCrossSigning(userId: string, body: Record<string, any>): Promise<void>;
29
30
  uploadSignatures(signer: string, body: Record<string, any>): Promise<Record<string, any>>;
30
31
  /** Users (sharing a room with `userId`) whose device lists changed after `since`. */
31
- changesSince(userId: string, since: number, upTo: number): Promise<{
32
+ /**
33
+ * Device-list changes for /sync and /keys/changes. /sync passes what it has already read (the
34
+ * membership changes, the device-list rows, the user's joined rooms) to save the queries.
35
+ */
36
+ changesSince(userId: string, since: number, upTo: number, known?: {
37
+ memberChanges?: Row[];
38
+ deviceRows?: Row[];
39
+ joinedRooms?: string[];
40
+ }): Promise<{
32
41
  changed: string[];
33
42
  left: string[];
34
43
  }>;
package/dist/core/keys.js CHANGED
@@ -99,39 +99,45 @@ var KeyService = class {
99
99
  return this.oneTimeKeyCounts(userId, deviceId);
100
100
  }
101
101
  async oneTimeKeyCounts(userId, deviceId) {
102
- const rows = await this.hs.db.findMany("one_time_keys", { where: [
103
- {
104
- field: "user_id",
105
- value: userId
106
- },
107
- {
108
- field: "device_id",
109
- value: deviceId
110
- },
111
- {
112
- field: "fallback",
113
- value: false
114
- }
115
- ] });
102
+ const rows = await this.hs.db.findMany("one_time_keys", {
103
+ where: [
104
+ {
105
+ field: "user_id",
106
+ value: userId
107
+ },
108
+ {
109
+ field: "device_id",
110
+ value: deviceId
111
+ },
112
+ {
113
+ field: "fallback",
114
+ value: false
115
+ }
116
+ ],
117
+ fields: ["algorithm"]
118
+ });
116
119
  const counts = { signed_curve25519: 0 };
117
120
  for (const r of rows) counts[r.algorithm] = (counts[r.algorithm] ?? 0) + 1;
118
121
  return counts;
119
122
  }
120
123
  async unusedFallbackKeyTypes(userId, deviceId) {
121
- const rows = await this.hs.db.findMany("fallback_keys", { where: [
122
- {
123
- field: "user_id",
124
- value: userId
125
- },
126
- {
127
- field: "device_id",
128
- value: deviceId
129
- },
130
- {
131
- field: "used",
132
- value: false
133
- }
134
- ] });
124
+ const rows = await this.hs.db.findMany("fallback_keys", {
125
+ where: [
126
+ {
127
+ field: "user_id",
128
+ value: userId
129
+ },
130
+ {
131
+ field: "device_id",
132
+ value: deviceId
133
+ },
134
+ {
135
+ field: "used",
136
+ value: false
137
+ }
138
+ ],
139
+ fields: ["algorithm"]
140
+ });
135
141
  return [...new Set(rows.map((r) => r.algorithm))];
136
142
  }
137
143
  async query(requester, deviceKeys, timeoutMs = 1e4) {
@@ -413,22 +419,18 @@ var KeyService = class {
413
419
  return { failures };
414
420
  }
415
421
  /** Users (sharing a room with `userId`) whose device lists changed after `since`. */
416
- async changesSince(userId, since, upTo) {
422
+ /**
423
+ * Device-list changes for /sync and /keys/changes. /sync passes what it has already read (the
424
+ * membership changes, the device-list rows, the user's joined rooms) to save the queries.
425
+ */
426
+ async changesSince(userId, since, upTo, known = {}) {
417
427
  const sharing = await this.hs.rooms.usersSharingRooms(userId);
418
- const rows = await this.hs.db.findMany("device_list_changes", { where: [{
419
- field: "stream",
420
- operator: "gt",
421
- value: since
422
- }] });
428
+ const rows = known.deviceRows ?? await this.hs.db.findMany("device_list_changes", deviceListChangesQuery(sharing, since));
423
429
  const changed = /* @__PURE__ */ new Set();
424
430
  for (const r of rows) if (r.stream <= upTo && sharing.has(r.user_id)) changed.add(r.user_id);
425
431
  const left = /* @__PURE__ */ new Set();
426
- const memberRows = await this.hs.db.findMany("memberships", { where: [{
427
- field: "stream",
428
- operator: "gt",
429
- value: since
430
- }] });
431
- const myRooms = /* @__PURE__ */ new Set([...await this.hs.rooms.roomsForUser(userId), ...memberRows.filter((m) => m.user_id === userId).map((m) => m.room_id)]);
432
+ const memberRows = known.memberChanges ?? await this.hs.rooms.membershipChangesSince(userId, since);
433
+ const myRooms = /* @__PURE__ */ new Set([...known.joinedRooms ?? await this.hs.rooms.roomsForUser(userId), ...memberRows.filter((m) => m.user_id === userId).map((m) => m.room_id)]);
432
434
  for (const m of memberRows) {
433
435
  if (m.stream > upTo || !myRooms.has(m.room_id)) continue;
434
436
  if (m.user_id === userId) {
@@ -446,5 +448,22 @@ var KeyService = class {
446
448
  };
447
449
  }
448
450
  };
451
+ /**
452
+ * The device-list changes after `since` of the users sharing a room with someone.
453
+ * https://github.com/k18k/matrix-nodejs-homeserver/issues/31: this read every device-list change
454
+ * on the server since `since`; it asks for the users sharing a room only (up to a size where an IN
455
+ * list stays reasonable for the database).
456
+ */
457
+ function deviceListChangesQuery(sharing, since) {
458
+ return { where: [{
459
+ field: "stream",
460
+ operator: "gt",
461
+ value: since
462
+ }, ...sharing.size <= 5e3 ? [{
463
+ field: "user_id",
464
+ operator: "in",
465
+ value: [...sharing]
466
+ }] : []] };
467
+ }
449
468
  //#endregion
450
- export { KeyService };
469
+ export { KeyService, deviceListChangesQuery };
@@ -0,0 +1,16 @@
1
+ import { LookupAddress, LookupOptions } from "node:dns";
2
+ //#region src/core/outbound.d.ts
3
+ type LookupCallback = (err: NodeJS.ErrnoException | null, address: string | LookupAddress[], family?: number) => void;
4
+ /** Which addresses outbound requests to user- or remote-chosen destinations may connect to. */
5
+ export interface AddressPolicy {
6
+ allows(address: string): boolean;
7
+ /**
8
+ * A `dns.lookup` for http(s) requests that refuses disallowed addresses. It runs when the
9
+ * connection is made, so the address checked is the one connected to: a name cannot resolve to
10
+ * a public address for a check and to a private one for the request (DNS rebinding).
11
+ * Undefined when every address is allowed.
12
+ */
13
+ lookup?: (hostname: string, options: LookupOptions, callback: LookupCallback) => void;
14
+ }
15
+ //#endregion
16
+ export {};
@@ -0,0 +1,172 @@
1
+ import { errors } from "./errors.js";
2
+ import { lookup } from "node:dns/promises";
3
+ import { request } from "node:https";
4
+ import { BlockList, isIP } from "node:net";
5
+ import { lookup as lookup$1 } from "node:dns";
6
+ import { request as request$1 } from "node:http";
7
+ import { Readable } from "node:stream";
8
+ //#region src/core/outbound.ts
9
+ const privateRanges = new BlockList();
10
+ for (const [net, prefix] of [
11
+ ["0.0.0.0", 8],
12
+ ["10.0.0.0", 8],
13
+ ["100.64.0.0", 10],
14
+ ["127.0.0.0", 8],
15
+ ["169.254.0.0", 16],
16
+ ["172.16.0.0", 12],
17
+ ["192.0.0.0", 24],
18
+ ["192.168.0.0", 16],
19
+ ["198.18.0.0", 15],
20
+ ["224.0.0.0", 4],
21
+ ["240.0.0.0", 4]
22
+ ]) privateRanges.addSubnet(net, prefix, "ipv4");
23
+ for (const [net, prefix] of [
24
+ ["::", 128],
25
+ ["::1", 128],
26
+ ["fc00::", 7],
27
+ ["fe80::", 10],
28
+ ["ff00::", 8]
29
+ ]) privateRanges.addSubnet(net, prefix, "ipv6");
30
+ /** Whether an IP address is loopback, private, link-local, multicast or otherwise not public. */
31
+ function isPrivateAddress(address) {
32
+ const family = isIP(address);
33
+ return family !== 0 && privateRanges.check(address, family === 6 ? "ipv6" : "ipv4");
34
+ }
35
+ /** The policy for the `allowPrivateAddresses` option. */
36
+ function addressPolicy(allowPrivate) {
37
+ if (allowPrivate === true) return { allows: () => true };
38
+ const allowed = new BlockList();
39
+ for (const entry of Array.isArray(allowPrivate) ? allowPrivate : []) {
40
+ const [address, prefix] = entry.split("/");
41
+ const family = isIP(address);
42
+ if (family === 0) throw new Error(`allowPrivateAddresses: not an IP address or range: ${entry}`);
43
+ allowed.addSubnet(address, prefix === void 0 ? family === 6 ? 128 : 32 : Number(prefix), family === 6 ? "ipv6" : "ipv4");
44
+ }
45
+ const allows = (address) => !isPrivateAddress(address) || allowed.check(address, isIP(address) === 6 ? "ipv6" : "ipv4");
46
+ return {
47
+ allows,
48
+ lookup: (hostname, options, callback) => {
49
+ lookup$1(hostname, {
50
+ ...options,
51
+ all: true
52
+ }, (err, addresses) => {
53
+ if (err) return callback(err, []);
54
+ if (addresses.length === 0 || !addresses.every((a) => allows(a.address))) return callback(privateAddressError(hostname), []);
55
+ if (options.all) callback(null, addresses);
56
+ else callback(null, addresses[0].address, addresses[0].family);
57
+ });
58
+ }
59
+ };
60
+ }
61
+ function privateAddressError(host) {
62
+ return Object.assign(/* @__PURE__ */ new Error(`${host} resolves to a private address`), { code: "EPRIVATE" });
63
+ }
64
+ /** Refuse an IP literal the policy does not allow (literals are connected to without a lookup). */
65
+ function checkLiteral(policy, host) {
66
+ const literal = host.replace(/^\[|\]$/g, "");
67
+ if (isIP(literal) && !policy.allows(literal)) throw errors.forbidden("Address is private");
68
+ }
69
+ /**
70
+ * A `fetch` for user- or remote-chosen URLs that connects only to addresses `policy` allows.
71
+ * Redirects are never followed: callers that follow them send the next request through this
72
+ * function again, so each target is checked. Only http(s) URLs are accepted.
73
+ *
74
+ * With a custom `fetch` (the `fetch` option, e.g. for a proxy), the host is resolved and checked
75
+ * before the request instead. That leaves the DNS-rebinding window open; the custom client is the
76
+ * operator's to secure.
77
+ */
78
+ function policyFetch(policy, custom) {
79
+ return async (input, init = {}) => {
80
+ const url = new URL(input instanceof Request ? input.url : String(input));
81
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw errors.forbidden("Only http and https URLs can be fetched");
82
+ checkLiteral(policy, url.hostname);
83
+ if (custom) {
84
+ if (policy.lookup && !isIP(url.hostname.replace(/^\[|\]$/g, ""))) {
85
+ if (!(await lookup(url.hostname, { all: true })).every((a) => policy.allows(a.address))) throw errors.forbidden("Address is private");
86
+ }
87
+ return custom(input, {
88
+ ...init,
89
+ redirect: "manual"
90
+ });
91
+ }
92
+ return nodeFetch(url, init, policy);
93
+ };
94
+ }
95
+ const NULL_BODY = /* @__PURE__ */ new Set([
96
+ 101,
97
+ 103,
98
+ 204,
99
+ 205,
100
+ 304
101
+ ]);
102
+ function nodeFetch(url, init, policy) {
103
+ const method = (init.method ?? "GET").toUpperCase();
104
+ const headers = new Headers(init.headers);
105
+ const body = init.body === void 0 || init.body === null ? void 0 : typeof init.body === "string" || init.body instanceof URLSearchParams ? Buffer.from(String(init.body)) : Buffer.from(init.body);
106
+ if (body && init.body instanceof URLSearchParams && !headers.has("content-type")) headers.set("content-type", "application/x-www-form-urlencoded;charset=UTF-8");
107
+ const request$2 = url.protocol === "https:" ? request : request$1;
108
+ return new Promise((resolve, reject) => {
109
+ const req = request$2(url, {
110
+ method,
111
+ headers: Object.fromEntries(headers),
112
+ lookup: policy.lookup,
113
+ signal: init.signal ?? void 0
114
+ }, (res) => {
115
+ const out = new Headers();
116
+ for (let i = 0; i < res.rawHeaders.length; i += 2) out.append(res.rawHeaders[i], res.rawHeaders[i + 1]);
117
+ const status = res.statusCode ?? 502;
118
+ const nullBody = NULL_BODY.has(status) || method === "HEAD";
119
+ if (nullBody) res.resume();
120
+ resolve(new Response(nullBody ? null : Readable.toWeb(res), {
121
+ status,
122
+ statusText: res.statusMessage,
123
+ headers: out
124
+ }));
125
+ });
126
+ req.on("error", (err) => reject(err.code === "EPRIVATE" ? errors.forbidden("Address is private") : err));
127
+ req.end(body);
128
+ });
129
+ }
130
+ /** GET a URL through a `policyFetch`, following redirects (each one checked again) and capping the body. */
131
+ async function publicGet(fetchFn, rawUrl, options) {
132
+ let url = new URL(rawUrl);
133
+ const signal = AbortSignal.timeout(options.timeoutMs ?? 3e4);
134
+ for (let redirects = 0;; redirects++) {
135
+ const res = await fetchFn(url, {
136
+ redirect: "manual",
137
+ signal,
138
+ headers: options.headers
139
+ });
140
+ const location = res.headers.get("location");
141
+ if (res.status >= 300 && res.status < 400 && location) {
142
+ await res.body?.cancel();
143
+ if (redirects >= (options.maxRedirects ?? 5)) throw errors.notFound("Too many redirects");
144
+ url = new URL(location, url);
145
+ continue;
146
+ }
147
+ if (Number(res.headers.get("content-length")) > options.maxBytes && !options.truncate) {
148
+ await res.body?.cancel();
149
+ throw errors.tooLarge("Remote response is too large");
150
+ }
151
+ const chunks = [];
152
+ let size = 0;
153
+ for await (const chunk of res.body ?? []) {
154
+ size += chunk.length;
155
+ if (size > options.maxBytes) {
156
+ await res.body?.cancel().catch(() => {});
157
+ if (!options.truncate) throw errors.tooLarge("Remote response is too large");
158
+ chunks.push(chunk.subarray(0, chunk.length - (size - options.maxBytes)));
159
+ break;
160
+ }
161
+ chunks.push(chunk);
162
+ }
163
+ return {
164
+ status: res.status,
165
+ headers: res.headers,
166
+ body: Buffer.concat(chunks),
167
+ url
168
+ };
169
+ }
170
+ }
171
+ //#endregion
172
+ export { addressPolicy, checkLiteral, isPrivateAddress, policyFetch, publicGet };
package/dist/core/push.js CHANGED
@@ -106,6 +106,7 @@ var PushService = class {
106
106
  const pl = powerLevels(state, version);
107
107
  const senderLevel = userLevel(pl, event.sender);
108
108
  const thread = threadIdOf(event);
109
+ const notified = [];
109
110
  for (const userId of members) {
110
111
  if (userId === event.sender || !this.hs.isLocalUser(userId)) continue;
111
112
  const actions = evaluateRules(await loadRules(this.hs, userId), {
@@ -119,17 +120,33 @@ var PushService = class {
119
120
  });
120
121
  if (!actions || !actions.includes("notify")) continue;
121
122
  const tweaks = tweaksOf(actions);
122
- await this.hs.db.upsert("notifications", {
123
- user_id: userId,
124
- room_id: event.room_id,
125
- event_id: event.event_id,
126
- stream: ev.stream,
127
- highlight: tweaks.highlight === true,
128
- thread_id: threadIdOf(event),
129
- actions,
130
- ts: Date.now()
123
+ notified.push({
124
+ userId,
125
+ tweaks,
126
+ row: {
127
+ user_id: userId,
128
+ room_id: event.room_id,
129
+ event_id: event.event_id,
130
+ stream: ev.stream,
131
+ highlight: tweaks.highlight === true,
132
+ thread_id: thread,
133
+ actions,
134
+ ts: Date.now()
135
+ }
131
136
  });
132
- this.deliver(userId, ev, state, tweaks).catch((err) => this.hs.logger.warn("Push delivery failed", err));
137
+ }
138
+ if (notified.length === 0) return;
139
+ await this.hs.transaction(async (db) => {
140
+ for (const n of notified) await db.upsert("notifications", n.row);
141
+ });
142
+ const pushers = await this.hs.db.findMany("pushers", { where: [{
143
+ field: "user_id",
144
+ operator: "in",
145
+ value: notified.map((n) => n.userId)
146
+ }] });
147
+ for (const n of notified) {
148
+ const own = pushers.filter((p) => p.user_id === n.userId);
149
+ if (own.length) this.deliver(n.userId, ev, state, n.tweaks, own).catch((err) => this.hs.logger.warn("Push delivery failed", err));
133
150
  }
134
151
  }
135
152
  /** Unread notification counts since the user's read receipts. */
@@ -196,12 +213,7 @@ var PushService = class {
196
213
  threads
197
214
  };
198
215
  }
199
- async deliver(userId, ev, state, tweaks) {
200
- const pushers = await this.hs.db.findMany("pushers", { where: [{
201
- field: "user_id",
202
- value: userId
203
- }] });
204
- if (pushers.length === 0) return;
216
+ async deliver(userId, ev, state, tweaks, pushers) {
205
217
  const event = ev.pdu;
206
218
  const unread = (await this.unreadCounts(userId, event.room_id)).room.notification_count;
207
219
  for (const row of pushers) {
@@ -234,7 +246,7 @@ var PushService = class {
234
246
  notification.user_is_target = event.state_key === userId;
235
247
  }
236
248
  }
237
- const res = await this.hs.fetch(url, {
249
+ const res = await this.hs.publicFetch(url, {
238
250
  method: "POST",
239
251
  headers: { "content-type": "application/json" },
240
252
  body: JSON.stringify({ notification }),
@@ -1,5 +1,5 @@
1
1
  //#region src/core/ratelimit.d.ts
2
- /** A rate limiter keyed by an arbitrary string. Implement this to share limits across instances (e.g. Redis). */
2
+ /** A rate limiter keyed by an arbitrary string. Implement this to share limits across instances. */
3
3
  export interface RateLimiter {
4
4
  /** Consume one unit for `key` under `rule`; resolve with the delay to wait (0 when allowed). */
5
5
  consume(key: string, rule: RateLimitRule): Promise<number>;
@@ -1,7 +1,7 @@
1
1
  import { ClientEvent, PDU } from "./events.js";
2
2
  import { StateMap } from "./authrules.js";
3
- import { StateIds } from "./stateres.js";
4
3
  import { Row } from "../storage/types.js";
4
+ import { StateIds } from "./stateres.js";
5
5
  import { Homeserver } from "../homeserver.js";
6
6
  //#region src/core/rooms.d.ts
7
7
  export interface StoredEvent {
@@ -140,6 +140,15 @@ export declare class RoomService {
140
140
  /** Users that should be woken up for events in this room. */
141
141
  private interestedUsers;
142
142
  roomsForUser(userId: string, membership?: string): Promise<string[]>;
143
+ /**
144
+ * Membership changes after `since` in the rooms where `userId` has a membership (in any state),
145
+ * given the user's own membership rows if the caller has them.
146
+ *
147
+ * https://github.com/k18k/matrix-nodejs-homeserver/issues/31: /sync and device-list tracking
148
+ * read every membership change on the server since their token and kept those in the user's
149
+ * rooms; while many users joined, each sync parsed thousands of rows. This reads those rooms only.
150
+ */
151
+ membershipChangesSince(userId: string, since: number, mine?: Row[]): Promise<Row[]>;
143
152
  /** All users sharing at least one joined room with `userId` (including themselves). */
144
153
  usersSharingRooms(userId: string): Promise<Set<string>>;
145
154
  private prepareRedaction;
@@ -1053,6 +1053,31 @@ var RoomService = class {
1053
1053
  value: membership
1054
1054
  }] })).map((r) => r.room_id);
1055
1055
  }
1056
+ /**
1057
+ * Membership changes after `since` in the rooms where `userId` has a membership (in any state),
1058
+ * given the user's own membership rows if the caller has them.
1059
+ *
1060
+ * https://github.com/k18k/matrix-nodejs-homeserver/issues/31: /sync and device-list tracking
1061
+ * read every membership change on the server since their token and kept those in the user's
1062
+ * rooms; while many users joined, each sync parsed thousands of rows. This reads those rooms only.
1063
+ */
1064
+ async membershipChangesSince(userId, since, mine) {
1065
+ const own = mine ?? await this.hs.db.findMany("memberships", { where: [{
1066
+ field: "user_id",
1067
+ value: userId
1068
+ }] });
1069
+ const rooms = [...new Set(own.map((m) => m.room_id))];
1070
+ if (rooms.length === 0) return [];
1071
+ return this.hs.db.findMany("memberships", { where: [{
1072
+ field: "room_id",
1073
+ operator: "in",
1074
+ value: rooms
1075
+ }, {
1076
+ field: "stream",
1077
+ operator: "gt",
1078
+ value: since
1079
+ }] });
1080
+ }
1056
1081
  /** All users sharing at least one joined room with `userId` (including themselves). */
1057
1082
  async usersSharingRooms(userId) {
1058
1083
  const cached = this.sharingCache.get(userId);
@@ -0,0 +1,23 @@
1
+ //#region src/core/semaphore.ts
2
+ /** Runs at most `limit` tasks at once; the others wait their turn, first come first served. */
3
+ var Semaphore = class {
4
+ limit;
5
+ active = 0;
6
+ waiting = [];
7
+ constructor(limit) {
8
+ this.limit = limit;
9
+ }
10
+ async run(fn) {
11
+ if (this.active >= this.limit) await new Promise((resolve) => this.waiting.push(resolve));
12
+ else this.active++;
13
+ try {
14
+ return await fn();
15
+ } finally {
16
+ const next = this.waiting.shift();
17
+ if (next) next();
18
+ else this.active--;
19
+ }
20
+ }
21
+ };
22
+ //#endregion
23
+ export { Semaphore };
@@ -28,11 +28,25 @@ export interface SsoProvider {
28
28
  }
29
29
  export interface SsoOptions {
30
30
  providers: SsoProvider[];
31
+ /**
32
+ * Clients that receive the login token straight away, as URL prefixes such as
33
+ * `"https://app.element.io/"` (same scheme and host, path starting with the prefix's path).
34
+ * Other clients get a page naming them, where the user confirms before the token is sent.
35
+ */
36
+ clientAllowlist?: string[];
37
+ }
38
+ /** Where the browser goes after login, and whether the user has to confirm that client first. */
39
+ export interface SsoResult {
40
+ url: string;
41
+ confirm: boolean;
42
+ /** The client as shown to the user: its host, or its scheme for app links. */
43
+ client: string;
31
44
  }
32
45
  /** Upstream OIDC login (authorization code + PKCE) issuing Matrix login tokens. */
33
46
  export declare class SsoService {
34
47
  private hs;
35
48
  readonly providers: SsoProvider[];
49
+ private clientAllowlist;
36
50
  private discovery;
37
51
  constructor(hs: Homeserver, options: SsoOptions);
38
52
  private callbackUrl;
@@ -40,7 +54,7 @@ export declare class SsoService {
40
54
  /** URL to send the user's browser to. */
41
55
  startLogin(providerId: string | null, redirectUrl: string): Promise<string>;
42
56
  /** Handle the provider's redirect; returns the client URL carrying a login token. */
43
- finishLogin(providerId: string, params: URLSearchParams): Promise<string>;
57
+ finishLogin(providerId: string, params: URLSearchParams): Promise<SsoResult>;
44
58
  private resolveUser;
45
59
  }
46
60
  //#endregion
package/dist/core/sso.js CHANGED
@@ -3,6 +3,34 @@ import { errors } from "./errors.js";
3
3
  import { LOCALPART_RE } from "./ids.js";
4
4
  import { createHash, randomBytes } from "node:crypto";
5
5
  //#region src/core/sso.ts
6
+ const UNSAFE_SCHEMES = /* @__PURE__ */ new Set([
7
+ "javascript:",
8
+ "data:",
9
+ "vbscript:",
10
+ "file:",
11
+ "blob:",
12
+ "about:"
13
+ ]);
14
+ /** The client's redirect URL, if it is one the server may send a login token to. */
15
+ function checkRedirectUrl(raw) {
16
+ if (!raw) return null;
17
+ try {
18
+ return UNSAFE_SCHEMES.has(new URL(raw).protocol) ? null : raw;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+ function clientMatches(raw, entry) {
24
+ try {
25
+ const url = new URL(raw);
26
+ const allowed = new URL(entry);
27
+ if (allowed.host) return url.protocol === allowed.protocol && url.host === allowed.host && url.pathname.startsWith(allowed.pathname);
28
+ return raw.startsWith(entry);
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+ const sameIssuer = (a, b) => typeof a === "string" && a.replace(/\/$/, "") === b.replace(/\/$/, "");
6
34
  function sanitizeLocalpart(raw) {
7
35
  return raw.toLowerCase().replace(/[^a-z0-9._=\-/+]/g, "_").slice(0, 200);
8
36
  }
@@ -15,10 +43,12 @@ function decodeJwtPayload(jwt) {
15
43
  var SsoService = class {
16
44
  hs;
17
45
  providers;
46
+ clientAllowlist;
18
47
  discovery = /* @__PURE__ */ new Map();
19
48
  constructor(hs, options) {
20
49
  this.hs = hs;
21
50
  this.providers = options.providers;
51
+ this.clientAllowlist = options.clientAllowlist ?? [];
22
52
  }
23
53
  callbackUrl(provider) {
24
54
  const base = this.hs.config.publicBaseUrl;
@@ -31,6 +61,7 @@ var SsoService = class {
31
61
  const res = await this.hs.fetch(`${provider.issuer.replace(/\/$/, "")}/.well-known/openid-configuration`, { signal: AbortSignal.timeout(1e4) });
32
62
  if (!res.ok) throw new Error(`OIDC discovery for ${provider.id} failed with ${res.status}`);
33
63
  const meta = await res.json();
64
+ if (!sameIssuer(meta.issuer, provider.issuer)) throw new Error(`OIDC discovery for ${provider.id} returned another issuer`);
34
65
  this.discovery.set(provider.id, meta);
35
66
  return meta;
36
67
  }
@@ -96,6 +127,7 @@ var SsoService = class {
96
127
  if (!res.ok) throw errors.forbidden("Code exchange failed");
97
128
  const tokens = await res.json();
98
129
  let claims = {};
130
+ if ((provider.scopes ?? ["openid"]).includes("openid") && typeof tokens.id_token !== "string") throw errors.forbidden("The provider returned no ID token");
99
131
  if (typeof tokens.id_token === "string") {
100
132
  claims = decodeJwtPayload(tokens.id_token);
101
133
  const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
@@ -130,7 +162,11 @@ var SsoService = class {
130
162
  });
131
163
  const redirect = new URL(pending.redirectUrl);
132
164
  redirect.searchParams.set("loginToken", loginToken);
133
- return redirect.toString();
165
+ return {
166
+ url: redirect.toString(),
167
+ confirm: !this.clientAllowlist.some((entry) => clientMatches(pending.redirectUrl, entry)),
168
+ client: redirect.host || redirect.protocol.replace(/:$/, "")
169
+ };
134
170
  }
135
171
  async resolveUser(provider, claims) {
136
172
  const link = await this.hs.db.findOne("sso_identities", [{
@@ -162,4 +198,4 @@ var SsoService = class {
162
198
  }
163
199
  };
164
200
  //#endregion
165
- export { SsoService };
201
+ export { SsoService, checkRedirectUrl };
@@ -1,6 +1,7 @@
1
1
  import { randomString } from "./crypto.js";
2
2
  import { MatrixError, errors } from "./errors.js";
3
3
  import { toMsisdn } from "./phone.js";
4
+ import { escapeHtml } from "./html.js";
4
5
  import { randomInt } from "node:crypto";
5
6
  //#region src/core/threepid.ts
6
7
  const CLIENT_SECRET_RE = /^[0-9a-zA-Z.=_-]{1,255}$/;
@@ -262,14 +263,5 @@ var ThreepidService = class {
262
263
  };
263
264
  }
264
265
  };
265
- function escapeHtml(s) {
266
- return s.replace(/[&<>"']/g, (c) => ({
267
- "&": "&amp;",
268
- "<": "&lt;",
269
- ">": "&gt;",
270
- "\"": "&quot;",
271
- "'": "&#39;"
272
- })[c]);
273
- }
274
266
  //#endregion
275
267
  export { ThreepidService, canonicalAddress, isMedium };