matrix-nodejs-homeserver 0.3.3 → 0.3.5

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 (59) hide show
  1. package/README.md +89 -44
  2. package/dist/api/account.js +109 -64
  3. package/dist/api/admin.js +9 -0
  4. package/dist/api/federation.js +1 -1
  5. package/dist/api/index.js +2 -0
  6. package/dist/api/longpoll.js +61 -0
  7. package/dist/api/misc.js +3 -3
  8. package/dist/api/oauthserver.js +620 -0
  9. package/dist/api/slidingsync.js +28 -30
  10. package/dist/api/sync.js +181 -120
  11. package/dist/api/threepid.js +1 -1
  12. package/dist/api/uia.js +20 -8
  13. package/dist/api/user.js +6 -3
  14. package/dist/core/admin.js +1 -0
  15. package/dist/core/auth.d.ts +33 -2
  16. package/dist/core/auth.js +84 -21
  17. package/dist/core/changes.d.ts +79 -0
  18. package/dist/core/changes.js +332 -0
  19. package/dist/core/cluster.d.ts +19 -3
  20. package/dist/core/cluster.js +44 -4
  21. package/dist/core/keys.js +4 -2
  22. package/dist/core/notifier.d.ts +49 -3
  23. package/dist/core/notifier.js +123 -26
  24. package/dist/core/oauth.js +2 -1
  25. package/dist/core/oauthserver.d.ts +141 -0
  26. package/dist/core/oauthserver.js +603 -0
  27. package/dist/core/push.d.ts +12 -11
  28. package/dist/core/push.js +6 -4
  29. package/dist/core/ratelimit.d.ts +1 -1
  30. package/dist/core/ratelimit.js +4 -0
  31. package/dist/core/rooms.js +7 -1
  32. package/dist/core/semaphore.js +1 -1
  33. package/dist/core/sso.d.ts +51 -15
  34. package/dist/core/sso.js +86 -26
  35. package/dist/homeserver.d.ts +19 -2
  36. package/dist/homeserver.js +33 -9
  37. package/dist/index.d.ts +3 -2
  38. package/dist/index.js +2 -1
  39. package/dist/storage/schema.js +20 -2
  40. package/dist/storage/sql.js +2 -2
  41. package/dist/vendor/zod/v4/classic/errors.js +1 -1
  42. package/dist/vendor/zod/v4/classic/parse.js +1 -1
  43. package/dist/vendor/zod/v4/classic/schemas.js +1 -1
  44. package/dist/vendor/zod/v4/core/api.js +1 -1
  45. package/dist/vendor/zod/v4/core/checks.js +1 -1
  46. package/dist/vendor/zod/v4/core/core.js +1 -1
  47. package/dist/vendor/zod/v4/core/doc.js +1 -1
  48. package/dist/vendor/zod/v4/core/errors.js +1 -1
  49. package/dist/vendor/zod/v4/core/json-schema-processors.js +1 -1
  50. package/dist/vendor/zod/v4/core/memoizer.js +1 -1
  51. package/dist/vendor/zod/v4/core/parse.js +1 -1
  52. package/dist/vendor/zod/v4/core/regexes.js +1 -1
  53. package/dist/vendor/zod/v4/core/registries.js +1 -1
  54. package/dist/vendor/zod/v4/core/schemas.js +1 -1
  55. package/dist/vendor/zod/v4/core/to-json-schema.js +1 -1
  56. package/dist/vendor/zod/v4/core/util.js +1 -1
  57. package/dist/vendor/zod/v4/core/versions.js +1 -1
  58. package/dist/vendor/zod/v4/locales/en.js +1 -1
  59. package/package.json +12 -15
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![Load test: SQLite](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fk18k%2Fmatrix-nodejs-homeserver%2Floadtest-results%2Fbadges%2Fsqlite.json)](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
5
5
  [![Load test: Postgres](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fk18k%2Fmatrix-nodejs-homeserver%2Floadtest-results%2Fbadges%2Fpg-nocluster.json)](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
6
6
  [![Load test: Postgres cluster, 1 node](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fk18k%2Fmatrix-nodejs-homeserver%2Floadtest-results%2Fbadges%2Fpg-cluster-1.json)](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
7
- [![Load test: Postgres cluster, 5 nodes](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fk18k%2Fmatrix-nodejs-homeserver%2Floadtest-results%2Fbadges%2Fpg-cluster-5.json)](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
7
+ [![Load test: Postgres cluster, 2 nodes](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fk18k%2Fmatrix-nodejs-homeserver%2Floadtest-results%2Fbadges%2Fpg-cluster-2.json)](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
8
8
 
9
9
  A [Matrix](https://matrix.org) homeserver as an importable Node.js library. It isn't
10
10
  tied to any HTTP framework, database, auth system or deployment shape. You can use
@@ -137,8 +137,9 @@ createHomeserver({
137
137
  `await hs.auth.createLoginToken(userId)` and let the client exchange the token via
138
138
  `m.login.token`. You can also call `hs.auth.createSession(userId)` to issue an
139
139
  access token directly.
140
- - **SSO with upstream OpenID Connect providers** (Google, GitHub via an OIDC
141
- bridge, Keycloak, Authentik, …). Users can sign in with `m.login.sso`:
140
+ - **SSO with upstream providers:** OpenID Connect (Google, Keycloak,
141
+ Authentik, Better Auth, …) or plain OAuth 2.0 such as GitHub
142
+ (`githubProvider({ clientId, clientSecret })`). Users can sign in with `m.login.sso`:
142
143
  ```ts
143
144
  sso: { providers: [{ id: "google", name: "Google", issuer: "https://accounts.google.com",
144
145
  clientId, clientSecret, mapUser: (claims) => ({ localpart: claims.email.split("@")[0] }) }] },
@@ -147,6 +148,37 @@ createHomeserver({
147
148
  After signing in, users confirm the client the login token goes to, on a page
148
149
  that names it. Clients you trust skip that page:
149
150
  `sso: { providers, clientAllowlist: ["https://app.element.io/"] }`.
151
+ - **The OAuth 2.0 API, served by the homeserver** (spec v1.15–v1.18). Clients
152
+ such as Element X register themselves, and users sign in, register, approve
153
+ the client and manage their account on the server's own web pages. Those pages
154
+ offer the password and the providers of `sso`. Login only with GitHub:
155
+ ```ts
156
+ import { createHomeserver, githubProvider } from "matrix-nodejs-homeserver";
157
+
158
+ createHomeserver({
159
+ homeserver: "example.com",
160
+ publicBaseUrl: "https://matrix.example.com",
161
+ oauthServer: { termsUrl: "https://example.com/terms", policyUrl: "https://example.com/privacy" },
162
+ userAuthAdapter: { passwordLoginEnabled: false },
163
+ sso: { providers: [githubProvider({ clientId, clientSecret })] },
164
+ });
165
+ ```
166
+ The GitHub OAuth app's callback URL is
167
+ `https://matrix.example.com/_matrix/client/v3/login/sso/callback/github`. The first
168
+ login creates the account. Users are matched by their GitHub ID, so a renamed
169
+ GitHub account keeps its Matrix account.
170
+
171
+ What is served: dynamic client registration with the spec's redirect URI
172
+ rules, the authorization code grant with PKCE (S256), the device authorization
173
+ grant (for CLIs and TVs), refresh tokens and revocation. It also covers
174
+ `prompt=create`, the account management page with every action the spec
175
+ lists, `m.oauth` for cross-signing resets, and ID tokens for clients that ask
176
+ for `openid`. Access tokens live 5 minutes (`accessTokenLifetimeMs`). Refresh
177
+ tokens rotate: a client whose response got lost can retry, and a refresh
178
+ token used again after it was replaced logs the device out. The pages are
179
+ under `/_synapse/client/oauth2/`. The legacy API stays available
180
+ (`legacyApi: false` turns it off), and its `m.login.sso` goes through the same
181
+ pages.
150
182
  - **Delegated OAuth 2.0** (the Matrix OAuth API, e.g. with
151
183
  matrix-authentication-service, Keycloak or Authentik). The provider owns the
152
184
  accounts, and the homeserver validates its access tokens by introspection:
@@ -342,8 +374,9 @@ paths (server admins only), so existing tools keep working.
342
374
  | `cluster` | Cluster adapter for running several instances. |
343
375
  | `adminRoom` | `true` or `{ firstUserIsAdmin, firstUserCanRegister, botLocalpart, … }`. Disabled by default. |
344
376
  | `audit` | `{ enabled, database, sink, actions, retentionDays }`. Disabled by default. |
345
- | `publicBaseUrl` | Advertised via `/.well-known/matrix/client`. Required for SSO. |
346
- | `oauth`, `sso` | Delegated OAuth or upstream OIDC SSO. |
377
+ | `publicBaseUrl` | Advertised via `/.well-known/matrix/client`. Required for SSO and `oauthServer`. |
378
+ | `oauthServer` | `true` or `{ legacyApi, accessTokenLifetimeMs, termsUrl, policyUrl, webSessionLifetimeMs }`: the OAuth 2.0 API, served by the homeserver. |
379
+ | `oauth`, `sso` | Delegated OAuth, and upstream identity providers (OIDC or OAuth 2.0, e.g. `githubProvider`). |
347
380
  | `federation` | `{ enabled, verifyTls, allowedServers, blockedServers, fasterJoins }`. Off unless `enabled: true`. |
348
381
  | `allowPrivateAddresses` | Lets requests to addresses that users or remote servers choose (federation, push gateways, identity servers, URL previews, remote media) reach private addresses: `true`, or ranges such as `["127.0.0.1/32"]`. Blocked by default. |
349
382
  | `rtc` | `{ turn, transports }` |
@@ -377,13 +410,14 @@ available for integration and metrics.
377
410
  - **Media:** legacy and authenticated endpoints, async uploads, thumbnails, the remote media proxy, URL previews
378
411
  - **Federation, VoIP/MatrixRTC, admin, server notices**
379
412
 
380
- Not implemented: third-party identifiers (email/phone), application services,
381
- and a built-in OAuth authorization server. For OAuth, delegate to an existing provider.
382
-
383
413
  ## Development
384
414
 
415
+ The repository is an npm workspace: this package is `packages/core`, and `apps/` holds the
416
+ public server built on it (see the repository's README). Install from the repository root;
417
+ the commands below run in `packages/core`.
418
+
385
419
  ```sh
386
- npm install
420
+ npm install # at the repository root
387
421
  npm run typecheck # tsc only type-checks (noEmit)
388
422
  npm run build # tsdown → dist/ (what the published package contains)
389
423
  node examples/node-http.ts # development runs the TypeScript sources directly (type stripping)
@@ -493,7 +527,7 @@ CI runs the whole suite with the same script. `complement/passing.txt` lists the
493
527
 
494
528
  ### Load testing
495
529
 
496
- Tuning and investigation runs are local; a [workflow](.github/workflows/loadtest.yml) runs the same scenarios on main for the badges. [loadtest/](loadtest/) holds a k6 scenario and a
530
+ Tuning and investigation runs are local; a [workflow](https://github.com/k18k/matrix-nodejs-homeserver/blob/main/.github/workflows/loadtest.yml) runs the same scenarios on main for the badges. [loadtest/](loadtest/) holds a k6 scenario and a
497
531
  homeserver that reports, every 10 s, requests per second, memory, event-loop delay and
498
532
  (with Postgres) how many queries wait for a pool connection and how long locks are held.
499
533
 
@@ -516,42 +550,53 @@ BASE_URL=http://localhost:8008 ONLINE_USERS=1000 MESSAGES_PER_SECOND=50 ROOMS=25
516
550
 
517
551
  <!-- loadtest-results:start -->
518
552
  The badges at the top come from the [Load test workflow](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml) on main: the same
519
- scenario on one GitHub runner (4 CPUs), with k6 on the same machine. Each run's job summary has
553
+ scenario on one GitHub runner (4 CPUs), with k6 on the same machine, and clusters of two nodes
554
+ (five nodes would compete with Postgres and k6 for four CPUs). Each run's job summary has
520
555
  the full table.
521
556
 
522
- Latest results: 1000 users online in 25 rooms (40 per room), 50 messages/s, 30 s ramp-up then
523
- 30 s at full load, on one developer machine (WSL2, Docker Desktop). Times are p50 / p95.
524
-
525
- | Setup | req/s | send | /sync | join | delivery | messages delivered | failed |
526
- |---|---|---|---|---|---|---|---|
527
- | SQLite, 1 process | 662 | 97 ms / 0.16 s | 0.87 s / 1.6 s | 10 ms / 91 ms | 1.64 s / 2.70 s | 53,935 | 0% |
528
- | Postgres, no cluster | 670 | 15 ms / 19 ms | 1.1 s / 1.8 s | 14 ms / 23 ms | 1.76 s / 2.31 s | 58,667 | 0% |
529
- | Postgres, cluster, 1 node | 593 | 27 ms / 38 ms | 1.3 s / 2.0 s | 36 ms / 51 ms | 1.96 s / 2.57 s | 58,493 | 0% |
530
- | Postgres, cluster, 5 nodes | 871 | 60 ms / 88 ms | 0.50 s / 1.1 s | 62 ms / 119 ms | 0.70 s / 1.09 s | 59,298 | 0% |
531
-
532
- What the load tests found and fixed so far: presence written on every `/sync` (#26), one commit
533
- per push notification (#27), a missing index (#28), Postgres cluster locks that could deadlock
534
- the pool (#29), stream writes serialized per instance (#30), `/sync` reading every change on the
535
- server (#31) and every joined room (#33), lock handover by polling (#32). Then `/sync` reads were batched
536
- (#34) and a process builds at most four sync responses at once (#35), so sends no longer queue
537
- behind the syncs a message wakes.
538
-
539
- Since 0.3.2 (#38–#45), Postgres delivery time fell by about 55% on one node and 50% on five:
540
- - changed rooms are built concurrently, with fewer reads each (#38);
541
- - push notifications of a message are written in one statement (#39);
542
- - Postgres statements are prepared once per connection (#40);
543
- - push rules are no longer copied per member (#41);
544
- - to-device acknowledgements run only when needed (#42);
545
- - reads issued at the same moment by concurrent requests share one statement (#43);
546
- - presence and device-list changes are found through a stream index (#44);
547
- - batched reads take half as long to plan (#45).
548
-
549
- On SQLite, commits no longer wait for an fsync (#46). Database calls also yield to the event
550
- loop (#47): before that, a wave of syncs could hold it for seconds, sends took 0.74 s and the
551
- server took in only about 40% of the messages the scenario sends.
552
-
553
- With every user receiving about two messages a second, delivery time is how long it takes a
554
- process to rebuild every user's `/sync` in turn: about 750 per second per Postgres node here.
557
+ Latest results, on one developer machine (WSL2, Docker Desktop): 1000 users online in 25 rooms
558
+ (40 per room), 30 s ramp-up, then 30 s of messages. Times are p50 / p95; "sent" is the share of
559
+ the messages the scenario asks for that the server accepted (a server that falls behind takes
560
+ fewer, and then looks faster than it is).
561
+
562
+ At 10 messages/s (every user receiving one message every 2.5 s):
563
+
564
+ | Setup | send | delivery | sent | failed |
565
+ |---|---|---|---|---|
566
+ | SQLite, 1 process | 5 ms / 15 ms | 14 ms / 0.18 s | 100% | 0% |
567
+ | Postgres, no cluster | 9 ms / 30 ms | 29 ms / 0.60 s | 100% | 0% |
568
+
569
+ At 50 messages/s (every user receiving two messages a second, 59,000 deliveries in 30 s):
570
+
571
+ | Setup | req/s | send | /sync | delivery | sent | failed |
572
+ |---|---|---|---|---|---|---|
573
+ | SQLite, 1 process | 722 | 11 ms / 25 ms | 0.77 s / 1.3 s | 1.2 s / 1.6 s | 100% | 0% |
574
+ | Postgres, no cluster | 687 | 35 ms / 49 ms | 0.79 s / 1.2 s | 1.2 s / 1.6 s | 100% | 0% |
575
+ | Postgres, cluster, 1 node | 766 | 72 ms / 98 ms | 1.1 s / 1.5 s | 1.7 s / 2.2 s | 100% | 0% |
576
+ | Postgres, cluster, 5 nodes | 910 | 146 ms / 233 ms | 0.42 s / 1.6 s | 0.37 s / 1.5 s | 100% | 0% |
577
+
578
+ At 50 messages/s, delivery time is how long a process takes to rebuild the `/sync` of every
579
+ user in turn, since every user has something new in nearly every rebuild.
580
+
581
+ What the load tests found and fixed:
582
+ - presence written on every `/sync` (#26), one commit per push notification (#27), a missing
583
+ index (#28), Postgres cluster locks that could deadlock the pool (#29), stream writes
584
+ serialized per instance (#30), `/sync` reading every change on the server (#31) and every
585
+ joined room (#33), lock handover by polling (#32), unbatched `/sync` reads (#34), syncs
586
+ crowding out sends (#35);
587
+ - in 0.3.3 (#38–#47): rooms built one after another (#38), a statement per push notification
588
+ (#39), Postgres re-planning every statement (#40), push rules copied per member (#41),
589
+ needless to-device deletes (#42), concurrent reads as separate statements (#43), presence and
590
+ device lists looked up per user (#44), costly batched reads (#45), an fsync per SQLite commit
591
+ (#46), a blocked event loop (#47);
592
+ - since 0.3.3: waiting `/sync` requests rebuilt whenever anything on the server changed, and
593
+ about two thirds of all rebuilds found nothing new (#50); a single instance now keeps track of
594
+ where data last changed, so `/sync` skips reads that would find nothing, like Synapse's stream
595
+ change caches (#51); queued syncs held the event loop, and yielding per statement made sends
596
+ slow (#52).
597
+
598
+ The load test itself was fixed too: its senders registered during the measurement, so a slow
599
+ server was sent fewer messages, and nginx could start after k6 (a red badge with "100% failed").
555
600
  <!-- loadtest-results:end -->
556
601
 
557
602
  ## License
@@ -9,8 +9,10 @@ import { canonicalAddress, isMedium } from "../core/threepid.js";
9
9
  import { boolean, looseObject, string } from "../vendor/zod/v4/classic/schemas.js";
10
10
  import { body, fail, lenient } from "../http/input.js";
11
11
  import { procedure } from "../http/procedure.js";
12
+ import { OAUTH_PREFIX } from "../core/oauthserver.js";
12
13
  import { requireUia } from "./uia.js";
13
- import { checkRedirectUrl } from "../core/sso.js";
14
+ import { checkRedirectUrl, legacyClientRedirect } from "../core/sso.js";
15
+ import { readWebSession, setWebUser } from "./oauthserver.js";
14
16
  import { createHmac, randomInt, timingSafeEqual } from "node:crypto";
15
17
  //#region src/api/account.ts
16
18
  const deviceFields = {
@@ -46,16 +48,18 @@ const sharedSecretRegisterBody = body({
46
48
  });
47
49
  function loginFlows(hs) {
48
50
  if (hs.oauth) return [];
51
+ if (hs.oauthServer && !hs.oauthServer.legacyApi) throw errors.unrecognized("Log in with the OAuth 2.0 API");
49
52
  const flows = [];
50
53
  if (hs.userAuth.passwordLoginEnabled) flows.push({ type: M.LOGIN.PASSWORD });
51
- if (hs.sso) flows.push({
54
+ if (hs.sso || hs.oauthServer) flows.push({
52
55
  type: M.LOGIN.SSO,
53
- identity_providers: hs.sso.providers.map((p) => ({
56
+ identity_providers: (hs.sso?.providers ?? []).map((p) => ({
54
57
  id: p.id,
55
58
  name: p.name,
56
59
  icon: p.icon,
57
60
  brand: p.brand
58
- }))
61
+ })),
62
+ ...hs.oauthServer ? { oauth_aware_preferred: true } : {}
59
63
  });
60
64
  flows.push({
61
65
  type: M.LOGIN.TOKEN,
@@ -65,6 +69,7 @@ function loginFlows(hs) {
65
69
  }
66
70
  /** Endpoints that manage passwords are unavailable when an OAuth provider owns the accounts. */
67
71
  function rejectIfDelegated(hs) {
72
+ if (hs.oauthServer && !hs.oauthServer.legacyApi) throw errors.unrecognized("Use the OAuth 2.0 API");
68
73
  if (hs.oauth) throw new MatrixError(403, "M_FORBIDDEN", "Accounts are managed by the OAuth provider", hs.oauth.options.accountManagementUrl ? { account_management_uri: hs.oauth.options.accountManagementUrl } : {});
69
74
  }
70
75
  async function validateNewUsername(hs, raw) {
@@ -105,6 +110,42 @@ async function createUser(hs, localpart, opts) {
105
110
  await hs.adminRoom?.onUserCreated(userId).catch((err) => hs.logger.error("Admin room setup failed", err));
106
111
  return userId;
107
112
  }
113
+ /**
114
+ * Check a password login, with the audit entries and automatic provisioning of `/login`; returns
115
+ * the user ID. Shared with the OAuth server's login page.
116
+ */
117
+ async function checkPasswordLogin(hs, userId, password, request) {
118
+ let user = await hs.db.findOne("users", [{
119
+ field: "user_id",
120
+ value: userId
121
+ }]);
122
+ if (!await hs.userAuth.checkPassword({
123
+ userId,
124
+ localpart: userId.slice(1).split(":")[0],
125
+ password,
126
+ storedHash: user?.password_hash ?? null
127
+ })) {
128
+ await hs.audit.record({
129
+ action: "login_failed",
130
+ actor: userId,
131
+ ip: request.ip,
132
+ userAgent: request.userAgent,
133
+ outcome: "failure",
134
+ details: { type: M.LOGIN.PASSWORD }
135
+ });
136
+ throw errors.forbidden("Invalid username or password");
137
+ }
138
+ if (!user) {
139
+ if (!await hs.userAuth.autoProvision(userId)) throw errors.forbidden("Invalid username or password");
140
+ await createUser(hs, userId.slice(1).split(":")[0], {});
141
+ user = await hs.db.findOne("users", [{
142
+ field: "user_id",
143
+ value: userId
144
+ }]);
145
+ }
146
+ if (user.deactivated) throw errors.userDeactivated();
147
+ return userId;
148
+ }
108
149
  async function resolveLoginUser(hs, body) {
109
150
  let user;
110
151
  const id = isObject(body.identifier) ? body.identifier : null;
@@ -138,45 +179,63 @@ async function resolveLoginUser(hs, body) {
138
179
  }
139
180
  return `@${user.toLowerCase()}:${hs.serverName}`;
140
181
  }
182
+ const ssoConfirmPage = (serverName, result) => {
183
+ const client = escapeHtml(result.client);
184
+ const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Continue to ${client}</title>
185
+ <style>body{font:16px system-ui,sans-serif;max-width:32rem;margin:3rem auto;padding:0 1rem}a.button{display:inline-block;padding:.6rem 1.2rem;background:#0b7a55;color:#fff;border-radius:6px;text-decoration:none}</style>
186
+ <h1>Continue to ${client}?</h1>
187
+ <p>You signed in to <b>${escapeHtml(serverName)}</b>. <b>${client}</b> will get access to your account.</p>
188
+ <p>If you did not just sign in with this app, close this page.</p>
189
+ <p><a class="button" href="${escapeHtml(result.url)}">Continue to ${client}</a></p>`;
190
+ return new Response(html, { headers: {
191
+ "content-type": "text/html; charset=utf-8",
192
+ "cache-control": "no-store",
193
+ "referrer-policy": "no-referrer",
194
+ "x-frame-options": "DENY",
195
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"
196
+ } });
197
+ };
198
+ const OAUTH_DISCOVERY_VERSIONS = ["v1", "unstable/org.matrix.msc2965"];
141
199
  function registerAccountRoutes(r) {
142
200
  r.client("GET", "/login", procedure({ handler: (ctx) => ({ flows: loginFlows(ctx.hs) }) }));
143
201
  r.client("GET", "/auth_metadata", procedure({ handler: async (ctx) => {
202
+ if (ctx.hs.oauthServer) return ctx.hs.oauthServer.metadata();
144
203
  if (!ctx.hs.oauth) throw errors.unrecognized("OAuth is not enabled");
145
204
  return ctx.hs.oauth.serverMetadata();
146
- } }), ["v1"]);
205
+ } }), OAUTH_DISCOVERY_VERSIONS);
147
206
  r.client("GET", "/auth_issuer", procedure({ handler: (ctx) => {
207
+ if (ctx.hs.oauthServer) return { issuer: ctx.hs.oauthServer.issuer };
148
208
  if (!ctx.hs.oauth) throw errors.unrecognized("OAuth is not enabled");
149
209
  return { issuer: ctx.hs.oauth.options.issuer };
150
- } }), ["v1"]);
151
- const ssoConfirmPage = (serverName, result) => {
152
- const client = escapeHtml(result.client);
153
- const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Continue to ${client}</title>
154
- <style>body{font:16px system-ui,sans-serif;max-width:32rem;margin:3rem auto;padding:0 1rem}a.button{display:inline-block;padding:.6rem 1.2rem;background:#0b7a55;color:#fff;border-radius:6px;text-decoration:none}</style>
155
- <h1>Continue to ${client}?</h1>
156
- <p>You signed in to <b>${escapeHtml(serverName)}</b>. <b>${client}</b> will get access to your account.</p>
157
- <p>If you did not just sign in with this app, close this page.</p>
158
- <p><a class="button" href="${escapeHtml(result.url)}">Continue to ${client}</a></p>`;
159
- return new Response(html, { headers: {
160
- "content-type": "text/html; charset=utf-8",
161
- "cache-control": "no-store",
162
- "referrer-policy": "no-referrer",
163
- "x-frame-options": "DENY",
164
- "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"
165
- } });
166
- };
210
+ } }), OAUTH_DISCOVERY_VERSIONS);
167
211
  const ssoRedirect = async (ctx) => {
168
- if (!ctx.hs.sso) throw errors.unrecognized("SSO is not enabled");
212
+ const hs = ctx.hs;
169
213
  const redirectUrl = checkRedirectUrl(ctx.query.get("redirectUrl"));
214
+ if (hs.oauthServer) {
215
+ if (!hs.oauthServer.legacyApi) throw errors.unrecognized("Log in with the OAuth 2.0 API");
216
+ if (!redirectUrl) throw errors.missingParam("Missing or invalid redirectUrl");
217
+ const id = randomString(24);
218
+ await hs.cluster.set(`oauth:legacy:${id}`, redirectUrl, 18e5);
219
+ const next = `${OAUTH_PREFIX}/legacy/${id}`;
220
+ const idp = ctx.params.idpId;
221
+ if (idp && !hs.sso?.providers.some((p) => p.id === idp)) throw errors.notFound("Unknown identity provider");
222
+ const page = idp ? `/upstream/${encodeURIComponent(idp)}` : ctx.query.get("action") === "register" ? "/register-account" : "/login";
223
+ return Response.redirect(`${hs.oauthServer.url(page)}?next=${encodeURIComponent(next)}`, 302);
224
+ }
225
+ if (!hs.sso) throw errors.unrecognized("SSO is not enabled");
170
226
  if (!redirectUrl) throw errors.missingParam("Missing or invalid redirectUrl");
171
- return Response.redirect(await ctx.hs.sso.startLogin(ctx.params.idpId ?? null, redirectUrl), 302);
227
+ return Response.redirect(await hs.sso.startLogin(ctx.params.idpId ?? null, {
228
+ kind: "client",
229
+ redirectUrl
230
+ }), 302);
172
231
  };
173
232
  r.client("POST", "/login/get_token", procedure({
174
233
  auth: "user",
175
234
  handler: async (ctx) => {
176
235
  const s = ctx.session;
177
- if (!ctx.hs.config.loginViaExistingSession || ctx.hs.oauth || s.isGuest) throw errors.unrecognized();
236
+ if (!ctx.hs.config.loginViaExistingSession || ctx.hs.oauth || s.oauth || s.isGuest) throw errors.unrecognized();
178
237
  await ctx.rateLimit("login");
179
- await requireUia(ctx.hs, await ctx.json(), [[M.LOGIN.PASSWORD]], s.userId);
238
+ await requireUia(ctx.hs, await ctx.json(), [[M.LOGIN.PASSWORD]], s.userId, { session: s });
180
239
  const ttl = 12e4;
181
240
  return {
182
241
  login_token: await ctx.hs.auth.createLoginToken(s.userId, ttl),
@@ -187,9 +246,17 @@ function registerAccountRoutes(r) {
187
246
  r.client("GET", "/login/sso/redirect", procedure({ handler: ssoRedirect }));
188
247
  r.client("GET", "/login/sso/redirect/:idpId", procedure({ handler: ssoRedirect }));
189
248
  r.client("GET", "/login/sso/callback/:idpId", procedure({ handler: async (ctx) => {
190
- if (!ctx.hs.sso) throw errors.unrecognized("SSO is not enabled");
191
- const result = await ctx.hs.sso.finishLogin(ctx.params.idpId, ctx.query);
192
- return result.confirm ? ssoConfirmPage(ctx.hs.serverName, result) : Response.redirect(result.url, 302);
249
+ const hs = ctx.hs;
250
+ if (!hs.sso) throw errors.unrecognized("SSO is not enabled");
251
+ const { userId, target } = await hs.sso.finishLogin(ctx.params.idpId, ctx.query);
252
+ if (target.kind === "web") {
253
+ const web = await readWebSession(ctx);
254
+ if (!web || web.sid !== target.sid) throw errors.forbidden("This login was started in another browser");
255
+ await setWebUser(hs, web, userId);
256
+ return Response.redirect(hs.oauthServer.url(target.next.slice(OAUTH_PREFIX.length)), 302);
257
+ }
258
+ const result = await legacyClientRedirect(hs, userId, target.redirectUrl);
259
+ return result.confirm ? ssoConfirmPage(hs.serverName, result) : Response.redirect(result.url, 302);
193
260
  } }));
194
261
  r.client("POST", "/login", procedure({
195
262
  rateLimit: "login",
@@ -198,6 +265,7 @@ function registerAccountRoutes(r) {
198
265
  const body = ctx.input;
199
266
  const hs = ctx.hs;
200
267
  if (hs.oauth) throw errors.unrecognized("Log in through the OAuth provider");
268
+ if (hs.oauthServer && !hs.oauthServer.legacyApi) throw errors.unrecognized("Log in with the OAuth 2.0 API");
201
269
  if (body.type === M.LOGIN.APPLICATION_SERVICE) {
202
270
  const reg = ctx.accessToken() ? hs.appservices.byToken(ctx.accessToken()) : null;
203
271
  if (!reg) throw errors.unknownToken("An application service token is required");
@@ -254,37 +322,11 @@ function registerAccountRoutes(r) {
254
322
  if (body.type !== M.LOGIN.PASSWORD || !hs.userAuth.passwordLoginEnabled) throw errors.unknown("Unsupported login type");
255
323
  const userId = await resolveLoginUser(hs, body);
256
324
  if (body.password === void 0) throw errors.missingParam("Missing password");
257
- let user = await hs.db.findOne("users", [{
258
- field: "user_id",
259
- value: userId
260
- }]);
261
- const ok = await hs.userAuth.checkPassword({
262
- userId,
263
- localpart: userId.slice(1).split(":")[0],
264
- password: body.password,
265
- storedHash: user?.password_hash ?? null
266
- });
267
325
  const ua = ctx.request.headers.get("user-agent");
268
- if (!ok) {
269
- await hs.audit.record({
270
- action: "login_failed",
271
- actor: userId,
272
- ip: ctx.ip,
273
- userAgent: ua,
274
- outcome: "failure",
275
- details: { type: M.LOGIN.PASSWORD }
276
- });
277
- throw errors.forbidden("Invalid username or password");
278
- }
279
- if (!user) {
280
- if (!await hs.userAuth.autoProvision(userId)) throw errors.forbidden("Invalid username or password");
281
- await createUser(hs, userId.slice(1).split(":")[0], {});
282
- user = await hs.db.findOne("users", [{
283
- field: "user_id",
284
- value: userId
285
- }]);
286
- }
287
- if (user.deactivated) throw errors.userDeactivated();
326
+ await checkPasswordLogin(hs, userId, body.password, {
327
+ ip: ctx.ip,
328
+ userAgent: ua
329
+ });
288
330
  const session = await hs.auth.createSession(userId, {
289
331
  deviceId: body.device_id,
290
332
  displayName: body.initial_device_display_name,
@@ -310,7 +352,7 @@ function registerAccountRoutes(r) {
310
352
  handler: async (ctx) => {
311
353
  const body = ctx.input;
312
354
  if (body.refresh_token === void 0) throw errors.missingParam("Missing refresh_token");
313
- const { access_token, refresh_token, expires_in_ms } = await ctx.hs.auth.refresh(body.refresh_token);
355
+ const { access_token, refresh_token, expires_in_ms } = await ctx.hs.auth.refresh(body.refresh_token, { clientId: null });
314
356
  return {
315
357
  access_token,
316
358
  refresh_token,
@@ -464,7 +506,7 @@ function registerAccountRoutes(r) {
464
506
  let s;
465
507
  if (ctx.accessToken()) {
466
508
  const session = await ctx.auth();
467
- await requireUia(hs, body, [[M.LOGIN.PASSWORD]], session.userId);
509
+ await requireUia(hs, body, [[M.LOGIN.PASSWORD]], session.userId, { session });
468
510
  s = {
469
511
  userId: session.userId,
470
512
  deviceId: session.deviceId
@@ -501,7 +543,7 @@ function registerAccountRoutes(r) {
501
543
  const hs = ctx.hs;
502
544
  rejectIfDelegated(hs);
503
545
  const s = await ctx.auth();
504
- await requireUia(hs, ctx.input, [[M.LOGIN.PASSWORD]], s.userId);
546
+ await requireUia(hs, ctx.input, [[M.LOGIN.PASSWORD]], s.userId, { session: s });
505
547
  for (const membership of [
506
548
  "join",
507
549
  "invite",
@@ -593,7 +635,10 @@ async function registrationTokenValid(hs, token) {
593
635
  }
594
636
  async function consumeRegistrationToken(hs, body) {
595
637
  if (body.auth?.type !== M.LOGIN.REGISTRATION_TOKEN) return;
596
- const token = body.auth?.token;
638
+ await redeemRegistrationToken(hs, body.auth?.token);
639
+ }
640
+ /** Count one use of a registration token. */
641
+ async function redeemRegistrationToken(hs, token) {
597
642
  const row = await hs.db.findOne("registration_tokens", [{
598
643
  field: "token",
599
644
  value: token
@@ -610,4 +655,4 @@ function wellKnown(hs) {
610
655
  return wk;
611
656
  }
612
657
  //#endregion
613
- export { createUser, registerAccountRoutes, registrationFlows, registrationTokenValid, wellKnown };
658
+ export { checkPasswordLogin, createUser, redeemRegistrationToken, registerAccountRoutes, registrationFlows, registrationTokenValid, ssoConfirmPage, validateNewUsername, wellKnown };
package/dist/api/admin.js CHANGED
@@ -2,6 +2,7 @@ import { errors } from "../core/errors.js";
2
2
  import { boolean, looseObject, number, string } from "../vendor/zod/v4/classic/schemas.js";
3
3
  import { body, fail, lenient } from "../http/input.js";
4
4
  import { procedure } from "../http/procedure.js";
5
+ import { allowCrossSigningReset } from "../core/oauthserver.js";
5
6
  //#region src/api/admin.ts
6
7
  const required = fail("M_BAD_JSON", "user_id and content are required");
7
8
  const noticeBody = body({
@@ -132,6 +133,14 @@ function registerAdminRoutes(r) {
132
133
  if (body.deactivated === true) await ctx.hs.admin.deactivateUser(userId);
133
134
  return ctx.hs.admin.getUser(userId);
134
135
  } }));
136
+ r.post("/_synapse/admin/v1/users/:userId/_allow_cross_signing_replacement_without_uia", procedure({
137
+ guard: adminGuard,
138
+ handler: async (ctx) => {
139
+ const userId = ctx.params.userId;
140
+ if (!ctx.hs.isLocalUser(userId) || !await ctx.hs.admin.getUser(userId)) throw errors.notFound("Unknown user");
141
+ return { updatable_without_uia_before_ms: await allowCrossSigningReset(ctx.hs, userId) };
142
+ }
143
+ }));
135
144
  r.post("/_synapse/admin/v1/deactivate/:userId", procedure({
136
145
  guard: adminGuard,
137
146
  input: body({ erase: lenient(boolean()) }),
@@ -11,8 +11,8 @@ import { domainOf, serverAllowedByAcl } from "../federation/index.js";
11
11
  import { array, looseObject, record, string, unknown } from "../vendor/zod/v4/classic/schemas.js";
12
12
  import { body, lenient } from "../http/input.js";
13
13
  import { procedure } from "../http/procedure.js";
14
- import { roomDetails } from "./spaces.js";
15
14
  import { fullProfile } from "../core/profile.js";
15
+ import { roomDetails } from "./spaces.js";
16
16
  import { relationshipsForServer } from "./relationships.js";
17
17
  import { canonicalJsonResponse } from "../http/handler.js";
18
18
  //#region src/api/federation.ts
package/dist/api/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { registerThreadSubscriptionRoutes } from "./threadsubs.js";
2
+ import { registerOAuthServerRoutes } from "./oauthserver.js";
2
3
  import { registerAccountRoutes } from "./account.js";
3
4
  import { registerBackupRoutes } from "./backup.js";
4
5
  import { registerRoomRoutes } from "./rooms.js";
@@ -23,6 +24,7 @@ import { registerUserRoutes } from "./user.js";
23
24
  function registerRoutes(r) {
24
25
  registerMiscRoutes(r);
25
26
  registerAccountRoutes(r);
27
+ registerOAuthServerRoutes(r);
26
28
  registerProfileRoutes(r);
27
29
  registerRoomRoutes(r);
28
30
  registerLegacyRoutes(r);
@@ -0,0 +1,61 @@
1
+ //#region src/api/longpoll.ts
2
+ /**
3
+ * The long-poll loop of `/sync` and sliding sync: build a response up to the current stream
4
+ * position, and while it has nothing new, wait for a change for `userId` and build again.
5
+ *
6
+ * https://github.com/k18k/matrix-nodejs-homeserver/issues/50: after a build with nothing new, the
7
+ * loop used to build again whenever the server-wide position had moved, which under load is
8
+ * always, so waiting requests rebuilt back to back. Now it registers for the user's wake-ups
9
+ * before reading the position, so none is missed during a build, and otherwise sleeps. A wake-up
10
+ * says which position its change has; if the next build cannot see it yet (another write in
11
+ * flight holds the position back), the loop waits for the position to get there.
12
+ *
13
+ * `build(to)` returns whether the response is final (`done`); returns the last built value, the
14
+ * position it was built at, and whether the request was aborted while waiting.
15
+ */
16
+ async function longPoll(hs, userId, timeoutMs, signal, build) {
17
+ const deadline = Date.now() + timeoutMs;
18
+ let pending = 0;
19
+ for (;;) {
20
+ const listener = hs.notifier.listen(userId);
21
+ pending = Math.max(pending, listener.recent);
22
+ let to;
23
+ let built;
24
+ try {
25
+ to = await hs.streams.position();
26
+ if (pending > to) to = await hs.streams.position(true);
27
+ built = await build(to);
28
+ } catch (err) {
29
+ listener.cancel();
30
+ throw err;
31
+ }
32
+ if (pending <= to) pending = 0;
33
+ const remaining = deadline - Date.now();
34
+ if (built.done || remaining <= 0 || signal?.aborted) {
35
+ listener.cancel();
36
+ return {
37
+ value: built.value,
38
+ to,
39
+ aborted: !!signal?.aborted
40
+ };
41
+ }
42
+ if (pending > to) {
43
+ listener.cancel();
44
+ if (await hs.streams.waitFor(pending, remaining, signal)) continue;
45
+ return {
46
+ value: built.value,
47
+ to,
48
+ aborted: !!signal?.aborted
49
+ };
50
+ }
51
+ const woke = await listener.wait(remaining, signal);
52
+ if (woke === null) return {
53
+ value: built.value,
54
+ to,
55
+ aborted: !!signal?.aborted
56
+ };
57
+ pending = Math.max(pending, woke);
58
+ }
59
+ }
60
+ //#endregion
61
+ export { longPoll };
package/dist/api/misc.js CHANGED
@@ -60,7 +60,7 @@ function registerMiscRoutes(r) {
60
60
  const available = {};
61
61
  for (const v of SUPPORTED_ROOM_VERSIONS) available[v] = /^\d+$/.test(v) ? "stable" : "unstable";
62
62
  return { capabilities: {
63
- [M.CHANGE_PASSWORD]: { enabled: !ctx.hs.oauth && ctx.hs.userAuth.passwordLoginEnabled },
63
+ [M.CHANGE_PASSWORD]: { enabled: !ctx.hs.oauth && !ctx.session.oauth && ctx.hs.userAuth.passwordLoginEnabled },
64
64
  [M.ROOM_VERSIONS]: {
65
65
  default: ctx.hs.config.defaultRoomVersion,
66
66
  available
@@ -68,8 +68,8 @@ function registerMiscRoutes(r) {
68
68
  [M.SET_DISPLAYNAME]: { enabled: true },
69
69
  [M.SET_AVATAR_URL]: { enabled: true },
70
70
  [M.PROFILE_FIELDS]: { enabled: true },
71
- [M.THREEPID_CHANGES]: { enabled: ctx.hs.threepids.enabled && !ctx.hs.oauth },
72
- [M.GET_LOGIN_TOKEN]: { enabled: ctx.hs.config.loginViaExistingSession && !ctx.hs.oauth }
71
+ [M.THREEPID_CHANGES]: { enabled: ctx.hs.threepids.enabled && !ctx.hs.oauth && !ctx.session.oauth },
72
+ [M.GET_LOGIN_TOKEN]: { enabled: ctx.hs.config.loginViaExistingSession && !ctx.hs.oauth && !ctx.session.oauth }
73
73
  } };
74
74
  }
75
75
  }));