matrix-nodejs-homeserver 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +43 -9
  2. package/dist/api/account.js +109 -64
  3. package/dist/api/admin.js +9 -0
  4. package/dist/api/federation.js +1 -1
  5. package/dist/api/index.js +2 -0
  6. package/dist/api/misc.js +3 -3
  7. package/dist/api/oauthserver.js +620 -0
  8. package/dist/api/sync.js +1 -1
  9. package/dist/api/threepid.js +1 -1
  10. package/dist/api/uia.js +20 -8
  11. package/dist/api/user.js +6 -3
  12. package/dist/core/auth.d.ts +33 -2
  13. package/dist/core/auth.js +84 -21
  14. package/dist/core/oauth.js +2 -1
  15. package/dist/core/oauthserver.d.ts +141 -0
  16. package/dist/core/oauthserver.js +603 -0
  17. package/dist/core/ratelimit.d.ts +1 -1
  18. package/dist/core/ratelimit.js +4 -0
  19. package/dist/core/sso.d.ts +51 -15
  20. package/dist/core/sso.js +86 -26
  21. package/dist/homeserver.d.ts +12 -1
  22. package/dist/homeserver.js +17 -2
  23. package/dist/index.d.ts +3 -2
  24. package/dist/index.js +2 -1
  25. package/dist/storage/schema.js +20 -2
  26. package/dist/vendor/zod/v4/classic/errors.js +1 -1
  27. package/dist/vendor/zod/v4/classic/parse.js +1 -1
  28. package/dist/vendor/zod/v4/classic/schemas.js +1 -1
  29. package/dist/vendor/zod/v4/core/api.js +1 -1
  30. package/dist/vendor/zod/v4/core/checks.js +1 -1
  31. package/dist/vendor/zod/v4/core/core.js +1 -1
  32. package/dist/vendor/zod/v4/core/doc.js +1 -1
  33. package/dist/vendor/zod/v4/core/errors.js +1 -1
  34. package/dist/vendor/zod/v4/core/json-schema-processors.js +1 -1
  35. package/dist/vendor/zod/v4/core/memoizer.js +1 -1
  36. package/dist/vendor/zod/v4/core/parse.js +1 -1
  37. package/dist/vendor/zod/v4/core/regexes.js +1 -1
  38. package/dist/vendor/zod/v4/core/registries.js +1 -1
  39. package/dist/vendor/zod/v4/core/schemas.js +1 -1
  40. package/dist/vendor/zod/v4/core/to-json-schema.js +1 -1
  41. package/dist/vendor/zod/v4/core/util.js +1 -1
  42. package/dist/vendor/zod/v4/core/versions.js +1 -1
  43. package/dist/vendor/zod/v4/locales/en.js +1 -1
  44. package/package.json +12 -15
@@ -0,0 +1,603 @@
1
+ import { canonicalJson } from "./json.js";
2
+ import { randomToken, urlSafeBase64 } from "./crypto.js";
3
+ import { createHash, randomInt, sign, timingSafeEqual } from "node:crypto";
4
+ //#region src/core/oauthserver.ts
5
+ /** Path prefix of every endpoint and page. Under `/_synapse/client`, which proxies already forward. */
6
+ const OAUTH_PREFIX = "/_synapse/client/oauth2";
7
+ const API_SCOPE = "urn:matrix:client:api:*";
8
+ const API_SCOPES = [API_SCOPE, "urn:matrix:org.matrix.msc2967.client:api:*"];
9
+ const DEVICE_SCOPE_PREFIXES = ["urn:matrix:client:device:", "urn:matrix:org.matrix.msc2967.client:device:"];
10
+ const GRANT_CODE = "authorization_code";
11
+ const GRANT_REFRESH = "refresh_token";
12
+ const GRANT_DEVICE = "urn:ietf:params:oauth:grant-type:device_code";
13
+ const SUPPORTED_GRANTS = [
14
+ GRANT_CODE,
15
+ GRANT_REFRESH,
16
+ GRANT_DEVICE
17
+ ];
18
+ const ACCOUNT_ACTIONS = [
19
+ "org.matrix.profile",
20
+ "org.matrix.devices_list",
21
+ "org.matrix.device_view",
22
+ "org.matrix.device_delete",
23
+ "org.matrix.account_deactivate",
24
+ "org.matrix.cross_signing_reset"
25
+ ];
26
+ const GRANT_TTL_MS = 18e5;
27
+ const CODE_TTL_MS = 6e5;
28
+ const DEVICE_CODE_TTL_MS = 9e5;
29
+ const DEVICE_POLL_INTERVAL_S = 5;
30
+ /** How long an approved cross-signing reset may be used (Synapse/MAS use the same window). */
31
+ const CROSS_SIGNING_RESET_WINDOW_MS = 6e5;
32
+ /** Registered clients without tokens are deleted after this long. */
33
+ const UNUSED_CLIENT_TTL_MS = 2592e6;
34
+ /** An OAuth error response (RFC 6749 section 5.2, RFC 7591 section 3.2.2). */
35
+ var OAuthError = class extends Error {
36
+ error;
37
+ description;
38
+ status;
39
+ constructor(error, description, status = 400) {
40
+ super(description);
41
+ this.error = error;
42
+ this.description = description;
43
+ this.status = status;
44
+ }
45
+ };
46
+ const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
47
+ "localhost",
48
+ "127.0.0.1",
49
+ "[::1]"
50
+ ]);
51
+ /** RFC 6749 section 3.3: the characters a scope token may use. */
52
+ const SCOPE_TOKEN_RE = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
53
+ /** Device IDs end up in URLs and keys: the unreserved characters of RFC 3986 only. */
54
+ const DEVICE_ID_RE = /^[A-Za-z0-9._~-]{1,255}$/;
55
+ const USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
56
+ const isHostUnder = (host, base) => host === base || host.endsWith(`.${base}`);
57
+ function parseUrl(raw) {
58
+ if (typeof raw !== "string") return null;
59
+ try {
60
+ return new URL(raw);
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+ /** An `https` URL without credentials, on the client's host or a subdomain of it. */
66
+ function checkHttpsUri(field, raw, clientHost) {
67
+ const url = parseUrl(raw);
68
+ if (!url || url.protocol !== "https:" || url.username || url.password) throw new OAuthError("invalid_client_metadata", `${field} must be an https URL without credentials`);
69
+ if (clientHost !== null && !isHostUnder(url.hostname, clientHost)) throw new OAuthError("invalid_client_metadata", `${field} must be on the host of client_uri or a subdomain of it`);
70
+ return raw;
71
+ }
72
+ /** Redirect URI validation of the spec ("Redirect URI validation", after RFC 8252 section 7). */
73
+ function checkRedirectUri(raw, applicationType, clientHost) {
74
+ const bad = (why) => {
75
+ throw new OAuthError("invalid_redirect_uri", `Invalid redirect URI ${raw}: ${why}`);
76
+ };
77
+ if (raw.includes("#")) bad("it may not have a fragment");
78
+ const url = parseUrl(raw) ?? bad("not a URL");
79
+ if (url.protocol === "https:") {
80
+ if (url.username || url.password) bad("it may not contain credentials");
81
+ if (!isHostUnder(url.hostname, clientHost)) bad("it must be on the host of client_uri or a subdomain of it");
82
+ return;
83
+ }
84
+ if (applicationType !== "native") bad("web clients must use https");
85
+ if (url.protocol === "http:") {
86
+ if (!LOOPBACK_HOSTS.has(url.hostname)) bad("http is only allowed on the loopback interface");
87
+ if (url.port) bad("loopback redirect URIs are registered without a port");
88
+ return;
89
+ }
90
+ const scheme = url.protocol.slice(0, -1);
91
+ const reversed = clientHost.split(".").reverse().join(".");
92
+ if (scheme !== reversed && !scheme.startsWith(`${reversed}.`)) bad(`the scheme must start with ${reversed}`);
93
+ if (raw.slice(url.protocol.length).startsWith("//")) bad("a private-use scheme may not have an authority");
94
+ }
95
+ /** Whether `given` is one of the client's redirect URIs; loopback URIs match on any port. */
96
+ function redirectUriMatches(registered, given) {
97
+ if (registered.includes(given)) return true;
98
+ const url = parseUrl(given);
99
+ if (!url || url.protocol !== "http:" || !LOOPBACK_HOSTS.has(url.hostname) || !url.port) return false;
100
+ url.port = "";
101
+ return registered.includes(url.toString()) || registered.includes(url.toString().replace(/\/$/, ""));
102
+ }
103
+ /** Validate a registration request and return the metadata as registered (RFC 7591 section 2). */
104
+ function validateClientMetadata(raw) {
105
+ const clientUri = checkHttpsUri("client_uri", raw.client_uri, null);
106
+ const host = new URL(clientUri).hostname;
107
+ const applicationType = raw.application_type ?? "web";
108
+ if (applicationType !== "web" && applicationType !== "native") throw new OAuthError("invalid_client_metadata", "application_type must be web or native");
109
+ const list = (field, fallback) => {
110
+ const v = raw[field];
111
+ if (v === void 0) return fallback;
112
+ if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) throw new OAuthError("invalid_client_metadata", `${field} must be a list of strings`);
113
+ return v;
114
+ };
115
+ const grantTypes = list("grant_types", [GRANT_CODE]).filter((g) => SUPPORTED_GRANTS.includes(g));
116
+ const responseTypes = list("response_types", ["code"]).filter((t) => t === "code");
117
+ const redirectUris = list("redirect_uris", []);
118
+ if (grantTypes.includes("authorization_code") && (redirectUris.length === 0 || responseTypes.length === 0)) throw new OAuthError("invalid_client_metadata", "The authorization code grant needs redirect_uris and the code response type");
119
+ if (grantTypes.length === 0) throw new OAuthError("invalid_client_metadata", "No supported grant type");
120
+ for (const uri of redirectUris) checkRedirectUri(uri, applicationType, host);
121
+ if ((raw.token_endpoint_auth_method ?? "none") !== "none") throw new OAuthError("invalid_client_metadata", "Only token_endpoint_auth_method none is supported");
122
+ const out = {
123
+ client_uri: clientUri,
124
+ redirect_uris: redirectUris,
125
+ response_types: responseTypes,
126
+ grant_types: grantTypes,
127
+ token_endpoint_auth_method: "none",
128
+ application_type: applicationType
129
+ };
130
+ if (raw.client_name !== void 0) {
131
+ if (typeof raw.client_name !== "string") throw new OAuthError("invalid_client_metadata", "client_name must be a string");
132
+ out.client_name = raw.client_name.slice(0, 200);
133
+ }
134
+ for (const field of [
135
+ "logo_uri",
136
+ "tos_uri",
137
+ "policy_uri"
138
+ ]) if (raw[field] !== void 0) out[field] = checkHttpsUri(field, raw[field], host);
139
+ return out;
140
+ }
141
+ /** The API scope and the one device ID a login scope must contain. */
142
+ function parseScope(scope) {
143
+ const tokens = (scope ?? "").split(" ").filter(Boolean);
144
+ if (!tokens.some((t) => API_SCOPES.includes(t))) throw new OAuthError("invalid_scope", `The scope must include ${API_SCOPE}`);
145
+ const devices = [];
146
+ let openid = false;
147
+ for (const t of tokens) {
148
+ if (!SCOPE_TOKEN_RE.test(t)) throw new OAuthError("invalid_scope", "Invalid scope token");
149
+ const prefix = DEVICE_SCOPE_PREFIXES.find((p) => t.startsWith(p));
150
+ if (prefix) devices.push(t.slice(prefix.length));
151
+ else if (t === "openid") openid = true;
152
+ else if (!API_SCOPES.includes(t)) throw new OAuthError("invalid_scope", `Unknown scope token ${t}`);
153
+ }
154
+ if (devices.length !== 1) throw new OAuthError("invalid_scope", "The scope must include exactly one device ID");
155
+ if (!DEVICE_ID_RE.test(devices[0])) throw new OAuthError("invalid_scope", "Device IDs may only use letters, digits and - . _ ~");
156
+ return {
157
+ scope: tokens.join(" "),
158
+ deviceId: devices[0],
159
+ openid
160
+ };
161
+ }
162
+ const s256 = (verifier) => urlSafeBase64(createHash("sha256").update(verifier).digest());
163
+ function safeEqual(a, b) {
164
+ const x = Buffer.from(a);
165
+ const y = Buffer.from(b);
166
+ return x.length === y.length && timingSafeEqual(x, y);
167
+ }
168
+ var OAuthServer = class {
169
+ hs;
170
+ options;
171
+ /** The issuer identifier: the public base URL with a trailing slash. */
172
+ issuer;
173
+ constructor(hs, options, publicBaseUrl) {
174
+ this.hs = hs;
175
+ this.options = options;
176
+ this.issuer = `${publicBaseUrl.replace(/\/$/, "")}/`;
177
+ }
178
+ get legacyApi() {
179
+ return this.options.legacyApi !== false;
180
+ }
181
+ get accessTokenLifetimeMs() {
182
+ return this.options.accessTokenLifetimeMs ?? 3e5;
183
+ }
184
+ /** Absolute URL of a path under the OAuth prefix. */
185
+ url(path) {
186
+ return `${this.issuer}${OAUTH_PREFIX.slice(1)}${path}`;
187
+ }
188
+ get accountManagementUri() {
189
+ return this.url("/account");
190
+ }
191
+ /** Authorization server metadata (RFC 8414), served at `/_matrix/client/v1/auth_metadata`. */
192
+ metadata() {
193
+ return {
194
+ issuer: this.issuer,
195
+ authorization_endpoint: this.url("/authorize"),
196
+ token_endpoint: this.url("/token"),
197
+ registration_endpoint: this.url("/register"),
198
+ revocation_endpoint: this.url("/revoke"),
199
+ device_authorization_endpoint: this.url("/device"),
200
+ userinfo_endpoint: this.url("/userinfo"),
201
+ jwks_uri: this.url("/keys.json"),
202
+ response_types_supported: ["code"],
203
+ response_modes_supported: ["query", "fragment"],
204
+ grant_types_supported: SUPPORTED_GRANTS,
205
+ code_challenge_methods_supported: ["S256"],
206
+ token_endpoint_auth_methods_supported: ["none"],
207
+ revocation_endpoint_auth_methods_supported: ["none"],
208
+ scopes_supported: [
209
+ API_SCOPE,
210
+ "urn:matrix:client:device:*",
211
+ "openid"
212
+ ],
213
+ subject_types_supported: ["public"],
214
+ id_token_signing_alg_values_supported: ["EdDSA"],
215
+ claims_supported: [
216
+ "iss",
217
+ "sub",
218
+ "aud",
219
+ "exp",
220
+ "iat",
221
+ "auth_time",
222
+ "nonce"
223
+ ],
224
+ prompt_values_supported: ["create"],
225
+ authorization_response_iss_parameter_supported: true,
226
+ account_management_uri: this.accountManagementUri,
227
+ account_management_actions_supported: ACCOUNT_ACTIONS,
228
+ ...this.options.termsUrl ? { op_tos_uri: this.options.termsUrl } : {},
229
+ ...this.options.policyUrl ? { op_policy_uri: this.options.policyUrl } : {}
230
+ };
231
+ }
232
+ /** The server's signing key as a JWK set (RFC 8037), for ID tokens. */
233
+ jwks() {
234
+ const key = this.hs.signingKey;
235
+ return { keys: [{
236
+ kty: "OKP",
237
+ crv: "Ed25519",
238
+ x: Buffer.from(key.publicKeyBase64, "base64").toString("base64url"),
239
+ kid: key.keyId,
240
+ alg: "EdDSA",
241
+ use: "sig"
242
+ }] };
243
+ }
244
+ /**
245
+ * Register a client. Each login registers again (the spec asks clients to), so identical
246
+ * metadata gets the same client ID, derived from it, instead of a new row every time.
247
+ */
248
+ async registerClient(raw) {
249
+ const metadata = validateClientMetadata(raw);
250
+ const clientId = urlSafeBase64(createHash("sha256").update(canonicalJson(metadata)).digest()).slice(0, 26);
251
+ const client = {
252
+ client_id: clientId,
253
+ ...metadata
254
+ };
255
+ if (!await this.hs.db.findOne("oauth_clients", [{
256
+ field: "client_id",
257
+ value: clientId
258
+ }])) await this.hs.db.upsert("oauth_clients", {
259
+ client_id: clientId,
260
+ metadata: client,
261
+ created_ts: Date.now()
262
+ });
263
+ return client;
264
+ }
265
+ async client(clientId) {
266
+ if (!clientId) return null;
267
+ const row = await this.hs.db.findOne("oauth_clients", [{
268
+ field: "client_id",
269
+ value: clientId
270
+ }]);
271
+ return row ? row.metadata : null;
272
+ }
273
+ /** Delete old registrations that no token uses (the spec forbids deleting ones in use). */
274
+ async prune() {
275
+ const old = await this.hs.db.findMany("oauth_clients", {
276
+ where: [{
277
+ field: "created_ts",
278
+ operator: "lt",
279
+ value: Date.now() - UNUSED_CLIENT_TTL_MS
280
+ }],
281
+ limit: 500
282
+ });
283
+ for (const c of old) if ((await this.hs.db.findMany("access_tokens", {
284
+ where: [{
285
+ field: "client_id",
286
+ value: c.client_id
287
+ }],
288
+ limit: 1
289
+ })).length === 0) await this.hs.db.delete("oauth_clients", [{
290
+ field: "client_id",
291
+ value: c.client_id
292
+ }]);
293
+ }
294
+ /**
295
+ * Check an authorization request. Problems with the client or the redirect URI are thrown as
296
+ * `OAuthError` with `redirect: false` semantics (shown to the user: RFC 6749 section 4.1.2.1
297
+ * forbids redirecting to an unverified URI); the rest come back as `{ error }` to redirect.
298
+ */
299
+ async checkAuthorizationRequest(params) {
300
+ const client = await this.client(params.get("client_id"));
301
+ if (!client) throw new OAuthError("invalid_client", "Unknown client");
302
+ const redirectUri = params.get("redirect_uri");
303
+ if (!redirectUri || !redirectUriMatches(client.redirect_uris, redirectUri)) throw new OAuthError("invalid_request", "The redirect URI is not registered for this client");
304
+ const responseMode = params.get("response_mode") ?? "query";
305
+ const state = params.get("state") ?? void 0;
306
+ const fail = (error, description) => ({
307
+ client,
308
+ redirect: this.callbackUrl(redirectUri, responseMode === "fragment" ? "fragment" : "query", {
309
+ error,
310
+ error_description: description,
311
+ state
312
+ }),
313
+ error: new OAuthError(error, description)
314
+ });
315
+ if (responseMode !== "query" && responseMode !== "fragment") return fail("invalid_request", "Unsupported response_mode");
316
+ if (params.get("response_type") !== "code") return fail("unsupported_response_type", "Only the code response type is supported");
317
+ if (!client.grant_types.includes("authorization_code")) return fail("unauthorized_client", "The client did not register the authorization code grant");
318
+ const challenge = params.get("code_challenge");
319
+ if (!challenge || params.get("code_challenge_method") !== "S256") return fail("invalid_request", "PKCE with the S256 method is required");
320
+ let scope;
321
+ try {
322
+ scope = parseScope(params.get("scope"));
323
+ } catch (err) {
324
+ if (err instanceof OAuthError) return fail(err.error, err.description);
325
+ throw err;
326
+ }
327
+ const prompt = (params.get("prompt") ?? "").split(" ").filter(Boolean);
328
+ if (prompt.includes("none")) return fail("interaction_required", "The user has to approve the client");
329
+ return {
330
+ client,
331
+ grant: {
332
+ clientId: client.client_id,
333
+ redirectUri,
334
+ responseMode,
335
+ ...state !== void 0 ? { state } : {},
336
+ scope: scope.scope,
337
+ deviceId: scope.deviceId,
338
+ codeChallenge: challenge,
339
+ ...params.get("nonce") ? { nonce: params.get("nonce") } : {},
340
+ create: prompt.includes("create")
341
+ }
342
+ };
343
+ }
344
+ /** The client's redirect URI with the response parameters (and RFC 9207 `iss`). */
345
+ callbackUrl(redirectUri, mode, params) {
346
+ const url = new URL(redirectUri);
347
+ const out = new URLSearchParams(mode === "query" ? url.search : "");
348
+ for (const [k, v] of Object.entries({
349
+ ...params,
350
+ iss: this.issuer
351
+ })) if (v !== void 0) out.set(k, v);
352
+ if (mode === "query") url.search = out.toString();
353
+ else url.hash = out.toString();
354
+ return url.toString();
355
+ }
356
+ async saveGrant(grant) {
357
+ const id = randomToken(24);
358
+ await this.hs.cluster.set(`oauth:grant:${id}`, grant, GRANT_TTL_MS);
359
+ return id;
360
+ }
361
+ grant(id) {
362
+ return this.hs.cluster.get(`oauth:grant:${id}`);
363
+ }
364
+ /** The user approved: issue a code and return where the browser goes next. */
365
+ async approveGrant(id, grant, userId, authTime) {
366
+ await this.hs.cluster.delete(`oauth:grant:${id}`);
367
+ const code = randomToken(32);
368
+ await this.hs.cluster.set(`oauth:code:${code}`, {
369
+ ...grant,
370
+ userId,
371
+ authTime
372
+ }, CODE_TTL_MS);
373
+ return this.callbackUrl(grant.redirectUri, grant.responseMode, {
374
+ code,
375
+ state: grant.state
376
+ });
377
+ }
378
+ async denyGrant(id, grant) {
379
+ await this.hs.cluster.delete(`oauth:grant:${id}`);
380
+ return this.callbackUrl(grant.redirectUri, grant.responseMode, {
381
+ error: "access_denied",
382
+ error_description: "The user denied access",
383
+ state: grant.state
384
+ });
385
+ }
386
+ async token(params, clientId) {
387
+ const client = await this.client(clientId);
388
+ if (!client) throw new OAuthError("invalid_client", "Unknown client", 401);
389
+ const grantType = params.get("grant_type");
390
+ if (!grantType || !SUPPORTED_GRANTS.includes(grantType)) throw new OAuthError("unsupported_grant_type", "Unsupported grant type");
391
+ if (!client.grant_types.includes(grantType)) throw new OAuthError("unauthorized_client", "The client did not register this grant type");
392
+ if (grantType === "authorization_code") return this.exchangeCode(client, params);
393
+ if (grantType === "urn:ietf:params:oauth:grant-type:device_code") return this.pollDevice(client, params.get("device_code"));
394
+ const refreshToken = params.get("refresh_token");
395
+ if (!refreshToken) throw new OAuthError("invalid_request", "Missing refresh_token");
396
+ let out;
397
+ try {
398
+ out = await this.hs.auth.refresh(refreshToken, {
399
+ clientId: client.client_id,
400
+ lifetimeMs: this.accessTokenLifetimeMs
401
+ });
402
+ } catch {
403
+ throw new OAuthError("invalid_grant", "The refresh token is invalid, expired or already used");
404
+ }
405
+ return this.tokenResponse(out, out.scope ?? `urn:matrix:client:api:* urn:matrix:client:device:${out.device_id}`);
406
+ }
407
+ async exchangeCode(client, params) {
408
+ const code = params.get("code");
409
+ if (!code) throw new OAuthError("invalid_request", "Missing code");
410
+ const issued = await this.hs.cluster.lock(`oauth:code:${code}`, async () => {
411
+ const found = await this.hs.cluster.get(`oauth:code:${code}`);
412
+ if (found) {
413
+ await this.hs.cluster.delete(`oauth:code:${code}`);
414
+ return found;
415
+ }
416
+ const used = await this.hs.cluster.get(`oauth:usedcode:${code}`);
417
+ if (used) {
418
+ this.hs.logger.warn("An authorization code was used twice; logging out", used.userId, used.deviceId);
419
+ await this.hs.auth.revokeDeviceTokens(used.userId, used.deviceId);
420
+ }
421
+ return null;
422
+ });
423
+ if (!issued) throw new OAuthError("invalid_grant", "Unknown, expired or used code");
424
+ if (issued.clientId !== client.client_id) throw new OAuthError("invalid_grant", "The code was issued to another client");
425
+ if (params.get("redirect_uri") !== issued.redirectUri) throw new OAuthError("invalid_grant", "redirect_uri does not match");
426
+ const verifier = params.get("code_verifier") ?? "";
427
+ if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier) || !safeEqual(s256(verifier), issued.codeChallenge)) throw new OAuthError("invalid_grant", "PKCE verification failed");
428
+ await this.hs.cluster.set(`oauth:usedcode:${code}`, {
429
+ userId: issued.userId,
430
+ deviceId: issued.deviceId
431
+ }, CODE_TTL_MS);
432
+ const out = await this.issue(client, issued.userId, issued.deviceId, issued.scope);
433
+ const response = this.tokenResponse(out, issued.scope);
434
+ if (issued.scope.split(" ").includes("openid")) response.id_token = this.idToken(client, issued.userId, issued.authTime, issued.nonce);
435
+ return response;
436
+ }
437
+ async issue(client, userId, deviceId, scope) {
438
+ const user = await this.hs.db.findOne("users", [{
439
+ field: "user_id",
440
+ value: userId
441
+ }]);
442
+ if (!user || user.deactivated) throw new OAuthError("invalid_grant", "The account is deactivated");
443
+ if (user.locked) throw new OAuthError("invalid_grant", "The account is locked");
444
+ const out = await this.hs.auth.createSession(userId, {
445
+ deviceId,
446
+ displayName: client.client_name,
447
+ refreshToken: true,
448
+ clientId: client.client_id,
449
+ lifetimeMs: this.accessTokenLifetimeMs,
450
+ scope
451
+ });
452
+ await this.hs.audit.record({
453
+ action: "oauth_login",
454
+ actor: userId,
455
+ target: deviceId,
456
+ details: { client_id: client.client_id }
457
+ });
458
+ return out;
459
+ }
460
+ tokenResponse(out, scope) {
461
+ return {
462
+ access_token: out.access_token,
463
+ token_type: "Bearer",
464
+ expires_in: Math.round(this.accessTokenLifetimeMs / 1e3),
465
+ refresh_token: out.refresh_token,
466
+ scope
467
+ };
468
+ }
469
+ /** An OpenID Connect ID token (EdDSA with the server's signing key). */
470
+ idToken(client, userId, authTime, nonce) {
471
+ const now = Math.floor(Date.now() / 1e3);
472
+ const enc = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
473
+ const header = enc({
474
+ alg: "EdDSA",
475
+ typ: "JWT",
476
+ kid: this.hs.signingKey.keyId
477
+ });
478
+ const payload = enc({
479
+ iss: this.issuer,
480
+ sub: userId,
481
+ aud: client.client_id,
482
+ iat: now,
483
+ exp: now + 3600,
484
+ auth_time: Math.floor(authTime / 1e3),
485
+ ...nonce ? { nonce } : {}
486
+ });
487
+ return `${header}.${payload}.${sign(null, Buffer.from(`${header}.${payload}`), this.hs.signingKey.privateKey).toString("base64url")}`;
488
+ }
489
+ async startDeviceGrant(clientId, scope) {
490
+ const client = await this.client(clientId);
491
+ if (!client) throw new OAuthError("invalid_client", "Unknown client", 401);
492
+ if (!client.grant_types.includes("urn:ietf:params:oauth:grant-type:device_code")) throw new OAuthError("unauthorized_client", "The client did not register the device grant");
493
+ const parsed = parseScope(scope);
494
+ const deviceCode = randomToken(32);
495
+ let userCode = "";
496
+ for (let i = 0; i < 8; i++) userCode += USER_CODE_ALPHABET[randomInt(20)];
497
+ userCode = `${userCode.slice(0, 4)}-${userCode.slice(4)}`;
498
+ const grant = {
499
+ clientId: client.client_id,
500
+ scope: parsed.scope,
501
+ deviceId: parsed.deviceId,
502
+ userCode,
503
+ status: "pending",
504
+ lastPoll: 0,
505
+ expires: Date.now() + DEVICE_CODE_TTL_MS
506
+ };
507
+ await this.hs.cluster.set(`oauth:device:${deviceCode}`, grant, DEVICE_CODE_TTL_MS);
508
+ await this.hs.cluster.set(`oauth:usercode:${userCode}`, deviceCode, DEVICE_CODE_TTL_MS);
509
+ return {
510
+ device_code: deviceCode,
511
+ user_code: userCode,
512
+ verification_uri: this.url("/link"),
513
+ verification_uri_complete: `${this.url("/link")}?code=${userCode}`,
514
+ expires_in: DEVICE_CODE_TTL_MS / 1e3,
515
+ interval: DEVICE_POLL_INTERVAL_S
516
+ };
517
+ }
518
+ /** The device grant a user code belongs to (typed in any case, with or without the dash). */
519
+ async deviceGrantByUserCode(raw) {
520
+ const clean = raw.toUpperCase().replace(/[^A-Z]/g, "");
521
+ if (clean.length !== 8) return null;
522
+ const userCode = `${clean.slice(0, 4)}-${clean.slice(4)}`;
523
+ const deviceCode = await this.hs.cluster.get(`oauth:usercode:${userCode}`);
524
+ const grant = deviceCode ? await this.hs.cluster.get(`oauth:device:${deviceCode}`) : null;
525
+ return deviceCode && grant && grant.status === "pending" ? {
526
+ deviceCode,
527
+ grant
528
+ } : null;
529
+ }
530
+ async decideDeviceGrant(deviceCode, grant, userId, approve) {
531
+ await this.hs.cluster.delete(`oauth:usercode:${grant.userCode}`);
532
+ const ttl = Math.max(1e3, grant.expires - Date.now());
533
+ await this.hs.cluster.set(`oauth:device:${deviceCode}`, {
534
+ ...grant,
535
+ status: approve ? "approved" : "denied",
536
+ userId
537
+ }, ttl);
538
+ }
539
+ async pollDevice(client, deviceCode) {
540
+ if (!deviceCode) throw new OAuthError("invalid_request", "Missing device_code");
541
+ const key = `oauth:device:${deviceCode}`;
542
+ return this.hs.cluster.lock(key, async () => {
543
+ const grant = await this.hs.cluster.get(key);
544
+ if (!grant) throw new OAuthError("expired_token", "The device code expired");
545
+ if (grant.clientId !== client.client_id) throw new OAuthError("invalid_grant", "The device code was issued to another client");
546
+ if (grant.status === "denied") {
547
+ await this.hs.cluster.delete(key);
548
+ throw new OAuthError("access_denied", "The user denied access");
549
+ }
550
+ if (grant.status === "pending") {
551
+ const now = Date.now();
552
+ const tooSoon = now - grant.lastPoll < DEVICE_POLL_INTERVAL_S * 1e3 - 500;
553
+ await this.hs.cluster.set(key, {
554
+ ...grant,
555
+ lastPoll: now
556
+ }, Math.max(1e3, grant.expires - now));
557
+ throw new OAuthError(tooSoon ? "slow_down" : "authorization_pending", tooSoon ? "Polling too often" : "Waiting for the user");
558
+ }
559
+ await this.hs.cluster.delete(key);
560
+ const out = await this.issue(client, grant.userId, grant.deviceId, grant.scope);
561
+ return this.tokenResponse(out, grant.scope);
562
+ });
563
+ }
564
+ /**
565
+ * Revoke an access or refresh token (RFC 7009). The spec revokes both tokens of the pair; a
566
+ * logout through this endpoint ends the device's session like `/logout` does. Unknown tokens
567
+ * are not an error.
568
+ */
569
+ async revoke(token) {
570
+ if (!token) throw new OAuthError("invalid_request", "Missing token");
571
+ const row = await this.hs.db.findOne("access_tokens", [{
572
+ field: "token",
573
+ value: token
574
+ }]) ?? await this.hs.db.findOne("access_tokens", [{
575
+ field: "refresh_token",
576
+ value: token
577
+ }]);
578
+ if (!row) return;
579
+ await this.hs.auth.deleteDevice(row.user_id, row.device_id);
580
+ await this.hs.audit.record({
581
+ action: "logout",
582
+ actor: row.user_id,
583
+ target: row.device_id,
584
+ details: { revoked: true }
585
+ });
586
+ }
587
+ };
588
+ /** Approve a cross-signing reset for `userId`; returns when the approval runs out. */
589
+ async function allowCrossSigningReset(hs, userId) {
590
+ const until = Date.now() + CROSS_SIGNING_RESET_WINDOW_MS;
591
+ await hs.cluster.set(`xsign-reset:${userId}`, until, CROSS_SIGNING_RESET_WINDOW_MS);
592
+ return until;
593
+ }
594
+ /**
595
+ * Whether the user approved a cross-signing reset in the last few minutes. The approval is not
596
+ * used up: a client may need several uploads (Synapse allows the same within the window).
597
+ */
598
+ async function crossSigningResetAllowed(hs, userId) {
599
+ const until = await hs.cluster.get(`xsign-reset:${userId}`);
600
+ return !!until && until > Date.now();
601
+ }
602
+ //#endregion
603
+ export { ACCOUNT_ACTIONS, API_SCOPE, CROSS_SIGNING_RESET_WINDOW_MS, GRANT_CODE, GRANT_DEVICE, GRANT_REFRESH, OAUTH_PREFIX, OAuthError, OAuthServer, allowCrossSigningReset, checkRedirectUri, crossSigningResetAllowed, parseScope, redirectUriMatches, validateClientMetadata };
@@ -10,7 +10,7 @@ export interface RateLimitRule {
10
10
  /** Burst size. */
11
11
  burst: number;
12
12
  }
13
- export type RateLimitAction = "login" | "register" | "message" | "join" | "invite" | "upload" | "createRoom" | "keyQuery";
13
+ export type RateLimitAction = "login" | "register" | "message" | "join" | "invite" | "upload" | "createRoom" | "keyQuery" | "oauthClient";
14
14
  /** In-process token bucket limiter. */
15
15
  export declare function memoryRateLimiter(): RateLimiter;
16
16
  export declare class RateLimits {
@@ -32,6 +32,10 @@ const DEFAULT_RATE_LIMITS = {
32
32
  keyQuery: {
33
33
  perSecond: 5,
34
34
  burst: 50
35
+ },
36
+ oauthClient: {
37
+ perSecond: .5,
38
+ burst: 10
35
39
  }
36
40
  };
37
41
  /** In-process token bucket limiter. */