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.
Files changed (55) hide show
  1. package/AUDITBOT.md +67 -0
  2. package/README.md +55 -9
  3. package/dist/api/account.js +109 -64
  4. package/dist/api/admin.js +14 -0
  5. package/dist/api/federation.js +1 -1
  6. package/dist/api/index.js +2 -0
  7. package/dist/api/misc.js +3 -3
  8. package/dist/api/oauthserver.js +620 -0
  9. package/dist/api/sync.js +1 -1
  10. package/dist/api/threepid.js +1 -1
  11. package/dist/api/uia.js +20 -8
  12. package/dist/api/user.js +6 -3
  13. package/dist/auditbot/bot.d.ts +37 -0
  14. package/dist/auditbot/bot.js +208 -0
  15. package/dist/auditbot/client.d.ts +41 -0
  16. package/dist/auditbot/client.js +156 -0
  17. package/dist/auditbot/index.d.ts +4 -0
  18. package/dist/auditbot/index.js +4 -0
  19. package/dist/auditbot/sinks.d.ts +37 -0
  20. package/dist/auditbot/sinks.js +33 -0
  21. package/dist/core/admin.d.ts +6 -0
  22. package/dist/core/admin.js +25 -0
  23. package/dist/core/auth.d.ts +33 -2
  24. package/dist/core/auth.js +84 -21
  25. package/dist/core/oauth.js +2 -1
  26. package/dist/core/oauthserver.d.ts +141 -0
  27. package/dist/core/oauthserver.js +603 -0
  28. package/dist/core/ratelimit.d.ts +1 -1
  29. package/dist/core/ratelimit.js +4 -0
  30. package/dist/core/sso.d.ts +51 -15
  31. package/dist/core/sso.js +86 -26
  32. package/dist/homeserver.d.ts +20 -9
  33. package/dist/homeserver.js +17 -2
  34. package/dist/index.d.ts +15 -14
  35. package/dist/index.js +2 -1
  36. package/dist/storage/schema.js +20 -2
  37. package/dist/vendor/zod/v4/classic/errors.js +1 -1
  38. package/dist/vendor/zod/v4/classic/parse.js +1 -1
  39. package/dist/vendor/zod/v4/classic/schemas.js +1 -1
  40. package/dist/vendor/zod/v4/core/api.js +1 -1
  41. package/dist/vendor/zod/v4/core/checks.js +1 -1
  42. package/dist/vendor/zod/v4/core/core.js +1 -1
  43. package/dist/vendor/zod/v4/core/doc.js +1 -1
  44. package/dist/vendor/zod/v4/core/errors.js +1 -1
  45. package/dist/vendor/zod/v4/core/json-schema-processors.js +1 -1
  46. package/dist/vendor/zod/v4/core/memoizer.js +1 -1
  47. package/dist/vendor/zod/v4/core/parse.js +1 -1
  48. package/dist/vendor/zod/v4/core/regexes.js +1 -1
  49. package/dist/vendor/zod/v4/core/registries.js +1 -1
  50. package/dist/vendor/zod/v4/core/schemas.js +1 -1
  51. package/dist/vendor/zod/v4/core/to-json-schema.js +1 -1
  52. package/dist/vendor/zod/v4/core/util.js +1 -1
  53. package/dist/vendor/zod/v4/core/versions.js +1 -1
  54. package/dist/vendor/zod/v4/locales/en.js +1 -1
  55. 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 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:
@@ -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
- | `oauth`, `sso` | Delegated OAuth or upstream OIDC SSO. |
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
 
@@ -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,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()) }),
@@ -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
  }));