matrix-nodejs-homeserver 0.3.4 → 0.3.6
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/AUDITBOT.md +67 -0
- package/README.md +55 -9
- package/dist/api/account.js +109 -64
- package/dist/api/admin.js +14 -0
- package/dist/api/federation.js +1 -1
- package/dist/api/index.js +2 -0
- package/dist/api/misc.js +3 -3
- package/dist/api/oauthserver.js +620 -0
- package/dist/api/sync.js +1 -1
- package/dist/api/threepid.js +1 -1
- package/dist/api/uia.js +20 -8
- package/dist/api/user.js +6 -3
- package/dist/auditbot/bot.d.ts +37 -0
- package/dist/auditbot/bot.js +208 -0
- package/dist/auditbot/client.d.ts +41 -0
- package/dist/auditbot/client.js +156 -0
- package/dist/auditbot/index.d.ts +4 -0
- package/dist/auditbot/index.js +4 -0
- package/dist/auditbot/sinks.d.ts +37 -0
- package/dist/auditbot/sinks.js +33 -0
- package/dist/core/admin.d.ts +6 -0
- package/dist/core/admin.js +25 -0
- package/dist/core/auth.d.ts +33 -2
- package/dist/core/auth.js +84 -21
- 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/ratelimit.d.ts +1 -1
- package/dist/core/ratelimit.js +4 -0
- package/dist/core/sso.d.ts +51 -15
- package/dist/core/sso.js +86 -26
- package/dist/homeserver.d.ts +20 -9
- package/dist/homeserver.js +17 -2
- package/dist/index.d.ts +15 -14
- package/dist/index.js +2 -1
- package/dist/storage/schema.js +20 -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 +28 -17
package/AUDITBOT.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Audit bot
|
|
2
|
+
|
|
3
|
+
`matrix-nodejs-homeserver/auditbot` records the rooms of the server, end-to-end encrypted ones
|
|
4
|
+
included, to an archive: an account of the server that is a member of every room (or every room
|
|
5
|
+
but direct messages) and writes each event as one JSON line.
|
|
6
|
+
|
|
7
|
+
It needs the crypto library, an optional peer dependency:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install matrix-nodejs-homeserver @matrix-org/matrix-sdk-crypto-nodejs
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createHomeserver } from "matrix-nodejs-homeserver";
|
|
15
|
+
import { fileSink, startAuditBot } from "matrix-nodejs-homeserver/auditbot";
|
|
16
|
+
|
|
17
|
+
const hs = createHomeserver({ /* … */ });
|
|
18
|
+
const bot = await startAuditBot(hs, {
|
|
19
|
+
sink: fileSink("/data/audit"), // {room ID}/{YYYY-MM-DD}.jsonl
|
|
20
|
+
dataDir: "/data/auditbot", // crypto store and sync position: keep and back up
|
|
21
|
+
storePassphrase: process.env.AUDIT_STORE_PASSPHRASE!,
|
|
22
|
+
rooms: "groups", // or "all" (default): with direct messages
|
|
23
|
+
});
|
|
24
|
+
// await bot.stop() on shutdown, before hs.close()
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## How it works
|
|
28
|
+
|
|
29
|
+
- **It is a room member.** It joins new rooms on its own, and existing ones at start and every
|
|
30
|
+
10 minutes. Public rooms are joined directly. In other rooms, a local member who may invite
|
|
31
|
+
invites it (`hs.admin.joinUser`, Synapse's `POST /_synapse/admin/v1/join`). The invite shows in
|
|
32
|
+
the room, and the bot is in the member list as "Audit log (this room is recorded)", so everyone
|
|
33
|
+
can see that a room is recorded.
|
|
34
|
+
- **End-to-end encryption.** It is a device like any other, with matrix-sdk-crypto (the Rust
|
|
35
|
+
crypto of the Element apps). Clients send it the room keys as they do to every member's device,
|
|
36
|
+
and it decrypts the events. Its device is signed by its own cross-signing identity. Keys live in
|
|
37
|
+
an encrypted SQLite store in `dataDir`, so it reads messages again after a restart.
|
|
38
|
+
- **Direct messages** are left out with `rooms: "groups"`. It does not join rooms created with
|
|
39
|
+
`is_direct`, and it leaves a room that turns out to be one.
|
|
40
|
+
- **In-process.** It runs in the homeserver's process and calls its API through the request
|
|
41
|
+
handler, with no network and no port.
|
|
42
|
+
|
|
43
|
+
What it cannot read: anything sent before it joined a room. It also misses messages from
|
|
44
|
+
clients set to share keys only with verified devices. A message whose key never arrives is
|
|
45
|
+
recorded after an hour with `decryption_error` and its ciphertext.
|
|
46
|
+
|
|
47
|
+
## Records
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{"recorded_at":1792270000000,"room_id":"!abc:example.com","event_id":"$…","type":"m.room.message",
|
|
51
|
+
"sender":"@alice:example.com","origin_server_ts":1792269999000,
|
|
52
|
+
"content":{"msgtype":"m.text","body":"hello"},"encrypted":true,"sender_device":"ABCDEFGHIJ"}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`state_key` and `redacts` appear when the event has them. `initial_state: true` marks state the
|
|
56
|
+
room had when the bot joined. A sink is anything with `write(records)`: `fileSink(dir)`,
|
|
57
|
+
`memorySink()`, or your own (S3, a SIEM, a database).
|
|
58
|
+
|
|
59
|
+
## Before you turn it on
|
|
60
|
+
|
|
61
|
+
Recording people's conversations, encrypted ones included, needs a legal basis and telling them:
|
|
62
|
+
terms of use, a privacy policy, and in the EU the GDPR. The bot's name says it in every room, but
|
|
63
|
+
it does not replace those.
|
|
64
|
+
|
|
65
|
+
The crypto library is a native module (Linux, macOS, Windows; x64 and arm64). Its install script
|
|
66
|
+
downloads it from the project's GitHub releases. With `--ignore-scripts`, run
|
|
67
|
+
`node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js`.
|
package/README.md
CHANGED
|
@@ -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:
|
|
@@ -313,6 +345,18 @@ up for admins (`/_synapse/admin/v1/event_reports`, `reports list`), and the admi
|
|
|
313
345
|
room is notified. `GET /_matrix/client/v3/admin/whois/{userId}` shows a user's
|
|
314
346
|
devices and IP addresses.
|
|
315
347
|
|
|
348
|
+
## Audit bot
|
|
349
|
+
|
|
350
|
+
`matrix-nodejs-homeserver/auditbot` records every room of the server to an archive, end-to-end
|
|
351
|
+
encrypted ones decrypted, like the Auditing of Element Server Suite Pro. The bot is a room
|
|
352
|
+
member with its own device, visible in member lists. See [AUDITBOT.md](https://github.com/k18k/matrix-nodejs-homeserver/blob/main/packages/core/AUDITBOT.md).
|
|
353
|
+
|
|
354
|
+
```ts
|
|
355
|
+
import { fileSink, startAuditBot } from "matrix-nodejs-homeserver/auditbot";
|
|
356
|
+
|
|
357
|
+
await startAuditBot(hs, { sink: fileSink("/data/audit"), dataDir: "/data/auditbot", storePassphrase, rooms: "groups" });
|
|
358
|
+
```
|
|
359
|
+
|
|
316
360
|
## Administration
|
|
317
361
|
|
|
318
362
|
You can use `hs.admin` from your own code:
|
|
@@ -342,8 +386,9 @@ paths (server admins only), so existing tools keep working.
|
|
|
342
386
|
| `cluster` | Cluster adapter for running several instances. |
|
|
343
387
|
| `adminRoom` | `true` or `{ firstUserIsAdmin, firstUserCanRegister, botLocalpart, … }`. Disabled by default. |
|
|
344
388
|
| `audit` | `{ enabled, database, sink, actions, retentionDays }`. Disabled by default. |
|
|
345
|
-
| `publicBaseUrl` | Advertised via `/.well-known/matrix/client`. Required for SSO
|
|
346
|
-
| `
|
|
389
|
+
| `publicBaseUrl` | Advertised via `/.well-known/matrix/client`. Required for SSO and `oauthServer`. |
|
|
390
|
+
| `oauthServer` | `true` or `{ legacyApi, accessTokenLifetimeMs, termsUrl, policyUrl, webSessionLifetimeMs }`: the OAuth 2.0 API, served by the homeserver. |
|
|
391
|
+
| `oauth`, `sso` | Delegated OAuth, and upstream identity providers (OIDC or OAuth 2.0, e.g. `githubProvider`). |
|
|
347
392
|
| `federation` | `{ enabled, verifyTls, allowedServers, blockedServers, fasterJoins }`. Off unless `enabled: true`. |
|
|
348
393
|
| `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
394
|
| `rtc` | `{ turn, transports }` |
|
|
@@ -377,13 +422,14 @@ available for integration and metrics.
|
|
|
377
422
|
- **Media:** legacy and authenticated endpoints, async uploads, thumbnails, the remote media proxy, URL previews
|
|
378
423
|
- **Federation, VoIP/MatrixRTC, admin, server notices**
|
|
379
424
|
|
|
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
425
|
## Development
|
|
384
426
|
|
|
427
|
+
The repository is an npm workspace: this package is `packages/core`, and `apps/` holds the
|
|
428
|
+
public server built on it (see the repository's README). Install from the repository root;
|
|
429
|
+
the commands below run in `packages/core`.
|
|
430
|
+
|
|
385
431
|
```sh
|
|
386
|
-
npm install
|
|
432
|
+
npm install # at the repository root
|
|
387
433
|
npm run typecheck # tsc only type-checks (noEmit)
|
|
388
434
|
npm run build # tsdown → dist/ (what the published package contains)
|
|
389
435
|
node examples/node-http.ts # development runs the TypeScript sources directly (type stripping)
|
|
@@ -493,7 +539,7 @@ CI runs the whole suite with the same script. `complement/passing.txt` lists the
|
|
|
493
539
|
|
|
494
540
|
### Load testing
|
|
495
541
|
|
|
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
|
|
542
|
+
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
543
|
homeserver that reports, every 10 s, requests per second, memory, event-loop delay and
|
|
498
544
|
(with Postgres) how many queries wait for a pool connection and how long locks are held.
|
|
499
545
|
|
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,19 @@ 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/join/:roomIdOrAlias", procedure({
|
|
137
|
+
guard: adminGuard,
|
|
138
|
+
input: body({ user_id: string(fail("M_MISSING_PARAM", "user_id is required", "user_id")) }),
|
|
139
|
+
handler: async (ctx) => ({ room_id: await ctx.hs.admin.joinUser(ctx.input.user_id, ctx.params.roomIdOrAlias) })
|
|
140
|
+
}));
|
|
141
|
+
r.post("/_synapse/admin/v1/users/:userId/_allow_cross_signing_replacement_without_uia", procedure({
|
|
142
|
+
guard: adminGuard,
|
|
143
|
+
handler: async (ctx) => {
|
|
144
|
+
const userId = ctx.params.userId;
|
|
145
|
+
if (!ctx.hs.isLocalUser(userId) || !await ctx.hs.admin.getUser(userId)) throw errors.notFound("Unknown user");
|
|
146
|
+
return { updatable_without_uia_before_ms: await allowCrossSigningReset(ctx.hs, userId) };
|
|
147
|
+
}
|
|
148
|
+
}));
|
|
135
149
|
r.post("/_synapse/admin/v1/deactivate/:userId", procedure({
|
|
136
150
|
guard: adminGuard,
|
|
137
151
|
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);
|
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
|
}));
|