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
@@ -13,6 +13,11 @@ export interface Session {
13
13
  suspended?: boolean;
14
14
  /** Set when the request was made with an application service token. */
15
15
  appservice?: AppServiceRegistration;
16
+ /**
17
+ * The token comes from the OAuth 2.0 API (this server's or a delegated one), so endpoints that
18
+ * need User-Interactive Authentication send the user to account management instead.
19
+ */
20
+ oauth?: boolean;
16
21
  }
17
22
  export interface CreatedSession {
18
23
  user_id: string;
@@ -20,6 +25,8 @@ export interface CreatedSession {
20
25
  access_token: string;
21
26
  refresh_token?: string;
22
27
  expires_in_ms?: number;
28
+ /** OAuth 2.0 API: the scope the tokens were issued for. */
29
+ scope?: string;
23
30
  }
24
31
  export declare class AuthService {
25
32
  private hs;
@@ -31,9 +38,33 @@ export declare class AuthService {
31
38
  deviceId?: string;
32
39
  displayName?: string;
33
40
  refreshToken?: boolean;
41
+ /** OAuth 2.0 API: the client the tokens are issued to. */
42
+ clientId?: string;
43
+ /** Access token lifetime; default `accessTokenLifetimeMs` (for tokens with a refresh token). */
44
+ lifetimeMs?: number;
45
+ /** OAuth 2.0 API: the scope granted. */
46
+ scope?: string;
47
+ }): Promise<CreatedSession>;
48
+ issueToken(userId: string, deviceId: string, withRefresh: boolean, opts?: {
49
+ clientId?: string | null;
50
+ lifetimeMs?: number;
51
+ parent?: string;
52
+ scope?: string | null;
53
+ }): Promise<CreatedSession>;
54
+ /**
55
+ * Exchange a refresh token for new tokens.
56
+ *
57
+ * https://github.com/k18k/matrix-nodejs-homeserver/issues/66: the old row used to be deleted
58
+ * at once, so a client whose response was lost could not retry and was logged out, and a
59
+ * refresh token used twice went unnoticed. As the OAuth 2.0 API asks (and the legacy API
60
+ * allows): the old refresh token keeps working until the new access token is first used; a
61
+ * retry before that replaces the unused tokens. Once the new token was used, the old refresh
62
+ * token is spent, and presenting it again means it leaked, so the device is logged out.
63
+ */
64
+ refresh(refreshToken: string, opts?: {
65
+ clientId?: string | null;
66
+ lifetimeMs?: number;
34
67
  }): Promise<CreatedSession>;
35
- issueToken(userId: string, deviceId: string, withRefresh: boolean): Promise<CreatedSession>;
36
- refresh(refreshToken: string): Promise<CreatedSession>;
37
68
  /** Drop cached sessions of a user (after admin/deactivation changes). */
38
69
  invalidateUser(userId: string): Promise<void>;
39
70
  /** Issue a short-lived single-use `m.login.token` for a user (SSO, or host-app sign-in). */
package/dist/core/auth.js CHANGED
@@ -11,13 +11,13 @@ var AuthService = class {
11
11
  if (msg?.from === hs.instanceId) return;
12
12
  if (msg.token) this.cache.delete(msg.token);
13
13
  if (msg.userId) {
14
- for (const [t, s] of this.cache) if (s.userId === msg.userId) this.cache.delete(t);
14
+ for (const [t, e] of this.cache) if (e.session.userId === msg.userId) this.cache.delete(t);
15
15
  }
16
16
  });
17
17
  }
18
18
  async authenticate(token, ip) {
19
19
  const cached = this.cache.get(token);
20
- if (cached) return cached;
20
+ if (cached && cached.expires > Date.now()) return cached.session;
21
21
  const row = await this.hs.db.findOne("access_tokens", [{
22
22
  field: "token",
23
23
  value: token
@@ -39,9 +39,24 @@ var AuthService = class {
39
39
  isGuest: !!user.is_guest,
40
40
  isAdmin: !!user.is_admin,
41
41
  locked: !!user.locked,
42
- suspended: !!user.suspended
42
+ suspended: !!user.suspended,
43
+ ...row.client_id ? { oauth: true } : {}
43
44
  };
44
- if (row.expires_ts === null) this.cache.set(token, session);
45
+ if (row.refresh_parent) {
46
+ await this.hs.db.update("access_tokens", [{
47
+ field: "token",
48
+ value: row.refresh_parent
49
+ }], { refresh_used: true });
50
+ await this.hs.db.update("access_tokens", [{
51
+ field: "token",
52
+ value: token
53
+ }], { refresh_parent: null });
54
+ }
55
+ if (this.cache.size > 1e5) this.cache.clear();
56
+ this.cache.set(token, {
57
+ session,
58
+ expires: row.expires_ts ?? Infinity
59
+ });
45
60
  await this.hs.db.update("devices", [{
46
61
  field: "user_id",
47
62
  value: row.user_id
@@ -91,46 +106,94 @@ var AuthService = class {
91
106
  target: deviceId
92
107
  });
93
108
  }
94
- return this.issueToken(userId, deviceId, !!opts.refreshToken);
109
+ return this.issueToken(userId, deviceId, !!opts.refreshToken, {
110
+ clientId: opts.clientId,
111
+ lifetimeMs: opts.lifetimeMs,
112
+ scope: opts.scope
113
+ });
95
114
  }
96
- async issueToken(userId, deviceId, withRefresh) {
115
+ async issueToken(userId, deviceId, withRefresh, opts = {}) {
97
116
  const token = `mat_${randomToken()}`;
98
- const lifetime = this.hs.config.accessTokenLifetimeMs;
117
+ const lifetime = opts.lifetimeMs ?? this.hs.config.accessTokenLifetimeMs;
99
118
  const refresh = withRefresh ? `mar_${randomToken()}` : null;
100
119
  await this.hs.db.create("access_tokens", {
101
120
  token,
102
121
  user_id: userId,
103
122
  device_id: deviceId,
104
123
  refresh_token: refresh,
105
- expires_ts: refresh && lifetime ? Date.now() + lifetime : null
124
+ expires_ts: refresh && lifetime ? Date.now() + lifetime : null,
125
+ client_id: opts.clientId ?? null,
126
+ scope: opts.scope ?? null,
127
+ refresh_parent: opts.parent ?? null,
128
+ refresh_used: false
106
129
  });
107
130
  const out = {
108
131
  user_id: userId,
109
132
  device_id: deviceId,
110
133
  access_token: token
111
134
  };
135
+ if (opts.scope) out.scope = opts.scope;
112
136
  if (refresh) {
113
137
  out.refresh_token = refresh;
114
138
  if (lifetime) out.expires_in_ms = lifetime;
115
139
  }
116
140
  return out;
117
141
  }
118
- async refresh(refreshToken) {
119
- const row = await this.hs.db.findOne("access_tokens", [{
120
- field: "refresh_token",
121
- value: refreshToken
122
- }]);
123
- if (!row) throw errors.unknownToken("Unknown refresh token");
124
- await this.hs.db.delete("access_tokens", [{
125
- field: "token",
126
- value: row.token
127
- }]);
128
- this.cache.delete(row.token);
129
- return this.issueToken(row.user_id, row.device_id, true);
142
+ /**
143
+ * Exchange a refresh token for new tokens.
144
+ *
145
+ * https://github.com/k18k/matrix-nodejs-homeserver/issues/66: the old row used to be deleted
146
+ * at once, so a client whose response was lost could not retry and was logged out, and a
147
+ * refresh token used twice went unnoticed. As the OAuth 2.0 API asks (and the legacy API
148
+ * allows): the old refresh token keeps working until the new access token is first used; a
149
+ * retry before that replaces the unused tokens. Once the new token was used, the old refresh
150
+ * token is spent, and presenting it again means it leaked, so the device is logged out.
151
+ */
152
+ async refresh(refreshToken, opts = {}) {
153
+ return this.hs.cluster.lock(`refresh:${refreshToken}`, async () => {
154
+ const row = await this.hs.db.findOne("access_tokens", [{
155
+ field: "refresh_token",
156
+ value: refreshToken
157
+ }]);
158
+ if (!row) throw errors.unknownToken("Unknown refresh token");
159
+ if (opts.clientId !== void 0 && (row.client_id ?? null) !== opts.clientId) throw errors.unknownToken("Unknown refresh token");
160
+ if (row.refresh_used) {
161
+ this.hs.logger.warn("A spent refresh token was used again; logging out", row.user_id, row.device_id);
162
+ await this.hs.audit.record({
163
+ action: "logout",
164
+ actor: row.user_id,
165
+ target: row.device_id,
166
+ details: { refresh_token_reuse: true }
167
+ });
168
+ await this.deleteDevice(row.user_id, row.device_id);
169
+ throw errors.unknownToken("Refresh token was already used");
170
+ }
171
+ for (const unused of await this.hs.db.findMany("access_tokens", { where: [{
172
+ field: "refresh_parent",
173
+ value: row.token
174
+ }] })) await this.revokeToken(unused.token);
175
+ if (row.expires_ts === null || row.expires_ts > Date.now()) {
176
+ await this.hs.db.update("access_tokens", [{
177
+ field: "token",
178
+ value: row.token
179
+ }], { expires_ts: Date.now() });
180
+ this.cache.delete(row.token);
181
+ await this.hs.cluster.publish("auth", {
182
+ from: this.hs.instanceId,
183
+ token: row.token
184
+ });
185
+ }
186
+ return this.issueToken(row.user_id, row.device_id, true, {
187
+ clientId: row.client_id,
188
+ lifetimeMs: opts.lifetimeMs,
189
+ parent: row.token,
190
+ scope: row.scope
191
+ });
192
+ });
130
193
  }
131
194
  /** Drop cached sessions of a user (after admin/deactivation changes). */
132
195
  async invalidateUser(userId) {
133
- for (const [token, s] of this.cache) if (s.userId === userId) this.cache.delete(token);
196
+ for (const [token, e] of this.cache) if (e.session.userId === userId) this.cache.delete(token);
134
197
  await this.hs.cluster.publish("auth", {
135
198
  from: this.hs.instanceId,
136
199
  userId
@@ -89,7 +89,8 @@ var OAuthDelegation = class {
89
89
  deviceId,
90
90
  accessToken: token,
91
91
  isGuest: false,
92
- isAdmin: !!user.is_admin || scopes.includes(this.options.adminScope ?? "urn:synapse:admin:*")
92
+ isAdmin: !!user.is_admin || scopes.includes(this.options.adminScope ?? "urn:synapse:admin:*"),
93
+ oauth: true
93
94
  };
94
95
  const ttl = this.options.cacheTtlMs ?? 6e4;
95
96
  const exp = typeof claims.exp === "number" ? claims.exp * 1e3 : Infinity;
@@ -0,0 +1,141 @@
1
+ import { Homeserver } from "../homeserver.js";
2
+ //#region src/core/oauthserver.d.ts
3
+ export interface OAuthServerOptions {
4
+ /**
5
+ * Keep the legacy login API (`/login`, `/register`) next to the OAuth 2.0 API. Its `m.login.sso`
6
+ * flow then goes through the same web login and is marked `oauth_aware_preferred`. Default: true.
7
+ */
8
+ legacyApi?: boolean;
9
+ /** Lifetime of access tokens issued through the OAuth 2.0 API, in ms. Default: 5 minutes. */
10
+ accessTokenLifetimeMs?: number;
11
+ /** Shown on the login and registration pages: the server's terms of service and privacy policy. */
12
+ termsUrl?: string;
13
+ policyUrl?: string;
14
+ /** How long a browser stays logged in to the web pages, in ms. Default: 7 days. */
15
+ webSessionLifetimeMs?: number;
16
+ }
17
+ /** An OAuth error response (RFC 6749 section 5.2, RFC 7591 section 3.2.2). */
18
+ export declare class OAuthError extends Error {
19
+ readonly error: string;
20
+ readonly description: string;
21
+ readonly status: number;
22
+ constructor(error: string, description: string, status?: number);
23
+ }
24
+ export interface ClientMetadata {
25
+ client_id: string;
26
+ client_uri: string;
27
+ client_name?: string;
28
+ logo_uri?: string;
29
+ tos_uri?: string;
30
+ policy_uri?: string;
31
+ redirect_uris: string[];
32
+ response_types: string[];
33
+ grant_types: string[];
34
+ token_endpoint_auth_method: "none";
35
+ application_type: "web" | "native";
36
+ }
37
+ /** A validated authorization request, waiting for the user to log in and consent. */
38
+ export interface PendingGrant {
39
+ clientId: string;
40
+ redirectUri: string;
41
+ responseMode: "query" | "fragment";
42
+ state?: string;
43
+ scope: string;
44
+ deviceId: string;
45
+ codeChallenge: string;
46
+ nonce?: string;
47
+ /** The client asked for the registration page (`prompt=create`). */
48
+ create: boolean;
49
+ }
50
+ export interface DeviceGrant {
51
+ clientId: string;
52
+ scope: string;
53
+ deviceId: string;
54
+ userCode: string;
55
+ status: "pending" | "approved" | "denied";
56
+ userId?: string;
57
+ lastPoll: number;
58
+ expires: number;
59
+ }
60
+ export interface TokenResponse {
61
+ access_token: string;
62
+ token_type: "Bearer";
63
+ expires_in: number;
64
+ refresh_token: string;
65
+ scope: string;
66
+ id_token?: string;
67
+ }
68
+ export declare class OAuthServer {
69
+ private hs;
70
+ readonly options: OAuthServerOptions;
71
+ /** The issuer identifier: the public base URL with a trailing slash. */
72
+ readonly issuer: string;
73
+ constructor(hs: Homeserver, options: OAuthServerOptions, publicBaseUrl: string);
74
+ get legacyApi(): boolean;
75
+ get accessTokenLifetimeMs(): number;
76
+ /** Absolute URL of a path under the OAuth prefix. */
77
+ url(path: string): string;
78
+ get accountManagementUri(): string;
79
+ /** Authorization server metadata (RFC 8414), served at `/_matrix/client/v1/auth_metadata`. */
80
+ metadata(): Record<string, unknown>;
81
+ /** The server's signing key as a JWK set (RFC 8037), for ID tokens. */
82
+ jwks(): {
83
+ keys: Record<string, string>[];
84
+ };
85
+ /**
86
+ * Register a client. Each login registers again (the spec asks clients to), so identical
87
+ * metadata gets the same client ID, derived from it, instead of a new row every time.
88
+ */
89
+ registerClient(raw: Record<string, unknown>): Promise<ClientMetadata>;
90
+ client(clientId: string | null): Promise<ClientMetadata | null>;
91
+ /** Delete old registrations that no token uses (the spec forbids deleting ones in use). */
92
+ prune(): Promise<void>;
93
+ /**
94
+ * Check an authorization request. Problems with the client or the redirect URI are thrown as
95
+ * `OAuthError` with `redirect: false` semantics (shown to the user: RFC 6749 section 4.1.2.1
96
+ * forbids redirecting to an unverified URI); the rest come back as `{ error }` to redirect.
97
+ */
98
+ checkAuthorizationRequest(params: URLSearchParams): Promise<{
99
+ client: ClientMetadata;
100
+ grant: PendingGrant;
101
+ } | {
102
+ client: ClientMetadata;
103
+ redirect: string;
104
+ error: OAuthError;
105
+ }>;
106
+ /** The client's redirect URI with the response parameters (and RFC 9207 `iss`). */
107
+ callbackUrl(redirectUri: string, mode: "query" | "fragment", params: Record<string, string | undefined>): string;
108
+ saveGrant(grant: PendingGrant): Promise<string>;
109
+ grant(id: string): Promise<PendingGrant | null>;
110
+ /** The user approved: issue a code and return where the browser goes next. */
111
+ approveGrant(id: string, grant: PendingGrant, userId: string, authTime: number): Promise<string>;
112
+ denyGrant(id: string, grant: PendingGrant): Promise<string>;
113
+ token(params: URLSearchParams, clientId: string | null): Promise<TokenResponse>;
114
+ private exchangeCode;
115
+ private issue;
116
+ private tokenResponse;
117
+ /** An OpenID Connect ID token (EdDSA with the server's signing key). */
118
+ private idToken;
119
+ startDeviceGrant(clientId: string | null, scope: string | null): Promise<{
120
+ device_code: string;
121
+ user_code: string;
122
+ verification_uri: string;
123
+ verification_uri_complete: string;
124
+ expires_in: number;
125
+ interval: number;
126
+ }>;
127
+ /** The device grant a user code belongs to (typed in any case, with or without the dash). */
128
+ deviceGrantByUserCode(raw: string): Promise<{
129
+ deviceCode: string;
130
+ grant: DeviceGrant;
131
+ } | null>;
132
+ decideDeviceGrant(deviceCode: string, grant: DeviceGrant, userId: string, approve: boolean): Promise<void>;
133
+ private pollDevice;
134
+ /**
135
+ * Revoke an access or refresh token (RFC 7009). The spec revokes both tokens of the pair; a
136
+ * logout through this endpoint ends the device's session like `/logout` does. Unknown tokens
137
+ * are not an error.
138
+ */
139
+ revoke(token: string | null): Promise<void>;
140
+ }
141
+ //#endregion