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.
- package/README.md +89 -44
- package/dist/api/account.js +109 -64
- package/dist/api/admin.js +9 -0
- package/dist/api/federation.js +1 -1
- package/dist/api/index.js +2 -0
- package/dist/api/longpoll.js +61 -0
- package/dist/api/misc.js +3 -3
- package/dist/api/oauthserver.js +620 -0
- package/dist/api/slidingsync.js +28 -30
- package/dist/api/sync.js +181 -120
- package/dist/api/threepid.js +1 -1
- package/dist/api/uia.js +20 -8
- package/dist/api/user.js +6 -3
- package/dist/core/admin.js +1 -0
- package/dist/core/auth.d.ts +33 -2
- package/dist/core/auth.js +84 -21
- package/dist/core/changes.d.ts +79 -0
- package/dist/core/changes.js +332 -0
- package/dist/core/cluster.d.ts +19 -3
- package/dist/core/cluster.js +44 -4
- package/dist/core/keys.js +4 -2
- package/dist/core/notifier.d.ts +49 -3
- package/dist/core/notifier.js +123 -26
- package/dist/core/oauth.js +2 -1
- package/dist/core/oauthserver.d.ts +141 -0
- package/dist/core/oauthserver.js +603 -0
- package/dist/core/push.d.ts +12 -11
- package/dist/core/push.js +6 -4
- package/dist/core/ratelimit.d.ts +1 -1
- package/dist/core/ratelimit.js +4 -0
- package/dist/core/rooms.js +7 -1
- package/dist/core/semaphore.js +1 -1
- package/dist/core/sso.d.ts +51 -15
- package/dist/core/sso.js +86 -26
- package/dist/homeserver.d.ts +19 -2
- package/dist/homeserver.js +33 -9
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/storage/schema.js +20 -2
- package/dist/storage/sql.js +2 -2
- package/dist/vendor/zod/v4/classic/errors.js +1 -1
- package/dist/vendor/zod/v4/classic/parse.js +1 -1
- package/dist/vendor/zod/v4/classic/schemas.js +1 -1
- package/dist/vendor/zod/v4/core/api.js +1 -1
- package/dist/vendor/zod/v4/core/checks.js +1 -1
- package/dist/vendor/zod/v4/core/core.js +1 -1
- package/dist/vendor/zod/v4/core/doc.js +1 -1
- package/dist/vendor/zod/v4/core/errors.js +1 -1
- package/dist/vendor/zod/v4/core/json-schema-processors.js +1 -1
- package/dist/vendor/zod/v4/core/memoizer.js +1 -1
- package/dist/vendor/zod/v4/core/parse.js +1 -1
- package/dist/vendor/zod/v4/core/regexes.js +1 -1
- package/dist/vendor/zod/v4/core/registries.js +1 -1
- package/dist/vendor/zod/v4/core/schemas.js +1 -1
- package/dist/vendor/zod/v4/core/to-json-schema.js +1 -1
- package/dist/vendor/zod/v4/core/util.js +1 -1
- package/dist/vendor/zod/v4/core/versions.js +1 -1
- package/dist/vendor/zod/v4/locales/en.js +1 -1
- package/package.json +12 -15
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
|
|
5
5
|
[](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
|
|
6
6
|
[](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
|
|
7
|
-
[](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
|
|
141
|
-
|
|
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
|
-
| `
|
|
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
|
|
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
|
|
523
|
-
30 s
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
package/dist/api/account.js
CHANGED
|
@@ -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
|
|
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
|
-
} }),
|
|
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
|
-
} }),
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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()) }),
|
package/dist/api/federation.js
CHANGED
|
@@ -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
|
}));
|