@chatroomcp/chatroom 0.1.3
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/LICENSE +202 -0
- package/README.md +50 -0
- package/assets/chatroom.svg +15 -0
- package/chatroom.config.example.json +24 -0
- package/dist/app/application.d.ts +18 -0
- package/dist/app/application.js +63 -0
- package/dist/app/event-bus.d.ts +23 -0
- package/dist/app/event-bus.js +12 -0
- package/dist/app/external-access-registry.d.ts +18 -0
- package/dist/app/external-access-registry.js +48 -0
- package/dist/app/lifecycle.d.ts +12 -0
- package/dist/app/lifecycle.js +41 -0
- package/dist/auth/auth-service.d.ts +88 -0
- package/dist/auth/auth-service.js +222 -0
- package/dist/auth/ingress-policy.d.ts +21 -0
- package/dist/auth/ingress-policy.js +57 -0
- package/dist/auth/passkey-service.d.ts +39 -0
- package/dist/auth/passkey-service.js +145 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +10 -0
- package/dist/cli/run-cli.d.ts +1 -0
- package/dist/cli/run-cli.js +42 -0
- package/dist/config/load-config.d.ts +7 -0
- package/dist/config/load-config.js +182 -0
- package/dist/config/platform-paths.d.ts +6 -0
- package/dist/config/platform-paths.js +34 -0
- package/dist/config/types.d.ts +25 -0
- package/dist/config/types.js +1 -0
- package/dist/core/auth/passkey-repository.d.ts +18 -0
- package/dist/core/auth/passkey-repository.js +1 -0
- package/dist/core/auth/repository.d.ts +59 -0
- package/dist/core/auth/repository.js +7 -0
- package/dist/core/auth/session-repository.d.ts +6 -0
- package/dist/core/auth/session-repository.js +1 -0
- package/dist/core/errors/chatroom-error.d.ts +23 -0
- package/dist/core/errors/chatroom-error.js +46 -0
- package/dist/core/operations/bounded-value.d.ts +6 -0
- package/dist/core/operations/bounded-value.js +41 -0
- package/dist/core/operations/redactor.d.ts +4 -0
- package/dist/core/operations/redactor.js +65 -0
- package/dist/core/operations/repository.d.ts +19 -0
- package/dist/core/operations/repository.js +1 -0
- package/dist/core/operations/types.d.ts +31 -0
- package/dist/core/operations/types.js +9 -0
- package/dist/core/runtime/child-environment.d.ts +1 -0
- package/dist/core/runtime/child-environment.js +39 -0
- package/dist/core/runtime/command-runner.d.ts +15 -0
- package/dist/core/runtime/command-runner.js +39 -0
- package/dist/core/runtime/identity.d.ts +2 -0
- package/dist/core/runtime/identity.js +9 -0
- package/dist/infrastructure/database/app-database.d.ts +6 -0
- package/dist/infrastructure/database/app-database.js +104 -0
- package/dist/infrastructure/database/oauth-repository.d.ts +20 -0
- package/dist/infrastructure/database/oauth-repository.js +140 -0
- package/dist/infrastructure/database/operation-repository.d.ts +16 -0
- package/dist/infrastructure/database/operation-repository.js +129 -0
- package/dist/infrastructure/database/passkey-repository.d.ts +11 -0
- package/dist/infrastructure/database/passkey-repository.js +57 -0
- package/dist/infrastructure/database/web-session-repository.d.ts +10 -0
- package/dist/infrastructure/database/web-session-repository.js +26 -0
- package/dist/infrastructure/http/http-server.d.ts +26 -0
- package/dist/infrastructure/http/http-server.js +166 -0
- package/dist/mcp/server/create-mcp-server.d.ts +3 -0
- package/dist/mcp/server/create-mcp-server.js +16 -0
- package/dist/mcp/server/tool-support.d.ts +105 -0
- package/dist/mcp/server/tool-support.js +124 -0
- package/dist/operations/operation-log.d.ts +23 -0
- package/dist/operations/operation-log.js +103 -0
- package/dist/plugins/cloud/api-client.d.ts +56 -0
- package/dist/plugins/cloud/api-client.js +122 -0
- package/dist/plugins/cloud/controller.d.ts +48 -0
- package/dist/plugins/cloud/controller.js +282 -0
- package/dist/plugins/cloud/device-proof.d.ts +9 -0
- package/dist/plugins/cloud/device-proof.js +17 -0
- package/dist/plugins/cloud/plugin.d.ts +4 -0
- package/dist/plugins/cloud/plugin.js +17 -0
- package/dist/plugins/cloud/state-store.d.ts +7 -0
- package/dist/plugins/cloud/state-store.js +74 -0
- package/dist/plugins/cloud/tunnel-client.d.ts +36 -0
- package/dist/plugins/cloud/tunnel-client.js +293 -0
- package/dist/plugins/cloud/types.d.ts +70 -0
- package/dist/plugins/cloud/types.js +32 -0
- package/dist/plugins/plugin-manager.d.ts +11 -0
- package/dist/plugins/plugin-manager.js +24 -0
- package/dist/plugins/process/backend.d.ts +19 -0
- package/dist/plugins/process/backend.js +1 -0
- package/dist/plugins/process/head-tail-buffer.d.ts +18 -0
- package/dist/plugins/process/head-tail-buffer.js +49 -0
- package/dist/plugins/process/infrastructure/pipe-process-backend.d.ts +8 -0
- package/dist/plugins/process/infrastructure/pipe-process-backend.js +61 -0
- package/dist/plugins/process/infrastructure/pty-process-backend.d.ts +8 -0
- package/dist/plugins/process/infrastructure/pty-process-backend.js +50 -0
- package/dist/plugins/process/mcp.d.ts +4 -0
- package/dist/plugins/process/mcp.js +69 -0
- package/dist/plugins/process/plugin.d.ts +4 -0
- package/dist/plugins/process/plugin.js +28 -0
- package/dist/plugins/process/process-supervisor.d.ts +36 -0
- package/dist/plugins/process/process-supervisor.js +232 -0
- package/dist/plugins/process/types.d.ts +34 -0
- package/dist/plugins/process/types.js +1 -0
- package/dist/plugins/types.d.ts +32 -0
- package/dist/plugins/types.js +17 -0
- package/dist/plugins/web/api-types.d.ts +10 -0
- package/dist/plugins/web/api-types.js +1 -0
- package/dist/plugins/web/http/api-router.d.ts +13 -0
- package/dist/plugins/web/http/api-router.js +146 -0
- package/dist/plugins/web/http/cloud-api-router.d.ts +4 -0
- package/dist/plugins/web/http/cloud-api-router.js +41 -0
- package/dist/plugins/web/http/process-api-router.d.ts +3 -0
- package/dist/plugins/web/http/process-api-router.js +16 -0
- package/dist/plugins/web/http/workspace-api-router.d.ts +3 -0
- package/dist/plugins/web/http/workspace-api-router.js +82 -0
- package/dist/plugins/web/plugin.d.ts +8 -0
- package/dist/plugins/web/plugin.js +17 -0
- package/dist/plugins/web/runtime.d.ts +36 -0
- package/dist/plugins/web/runtime.js +66 -0
- package/dist/plugins/workspace/domain/filesystem.d.ts +25 -0
- package/dist/plugins/workspace/domain/filesystem.js +1 -0
- package/dist/plugins/workspace/domain/git.d.ts +7 -0
- package/dist/plugins/workspace/domain/git.js +1 -0
- package/dist/plugins/workspace/domain/repository.d.ts +16 -0
- package/dist/plugins/workspace/domain/repository.js +1 -0
- package/dist/plugins/workspace/domain/review.d.ts +28 -0
- package/dist/plugins/workspace/domain/review.js +1 -0
- package/dist/plugins/workspace/domain/workspace.d.ts +23 -0
- package/dist/plugins/workspace/domain/workspace.js +1 -0
- package/dist/plugins/workspace/git/git-command-runner.d.ts +6 -0
- package/dist/plugins/workspace/git/git-command-runner.js +15 -0
- package/dist/plugins/workspace/git/git-service.d.ts +14 -0
- package/dist/plugins/workspace/git/git-service.js +107 -0
- package/dist/plugins/workspace/git/git-snapshot-service.d.ts +33 -0
- package/dist/plugins/workspace/git/git-snapshot-service.js +352 -0
- package/dist/plugins/workspace/infrastructure/workspace-fs.d.ts +38 -0
- package/dist/plugins/workspace/infrastructure/workspace-fs.js +340 -0
- package/dist/plugins/workspace/infrastructure/workspace-repository.d.ts +12 -0
- package/dist/plugins/workspace/infrastructure/workspace-repository.js +51 -0
- package/dist/plugins/workspace/mcp.d.ts +4 -0
- package/dist/plugins/workspace/mcp.js +168 -0
- package/dist/plugins/workspace/plugin.d.ts +10 -0
- package/dist/plugins/workspace/plugin.js +37 -0
- package/dist/plugins/workspace/workspace-helpers.d.ts +9 -0
- package/dist/plugins/workspace/workspace-helpers.js +67 -0
- package/dist/plugins/workspace/workspace-service.d.ts +46 -0
- package/dist/plugins/workspace/workspace-service.js +339 -0
- package/dist/plugins/workspace/worktree-review-service.d.ts +16 -0
- package/dist/plugins/workspace/worktree-review-service.js +51 -0
- package/dist/presentation/http/http-utils.d.ts +7 -0
- package/dist/presentation/http/http-utils.js +47 -0
- package/dist/presentation/http/oauth-router.d.ts +5 -0
- package/dist/presentation/http/oauth-router.js +163 -0
- package/dist/web/assets/chatroom-B5p-5n3y.svg +15 -0
- package/dist/web/assets/index-BF_mWl1D.js +26 -0
- package/dist/web/assets/index-JPZut9tc.css +1 -0
- package/dist/web/assets/materialdesignicons-webfont-B7mPwVP_.ttf +0 -0
- package/dist/web/assets/materialdesignicons-webfont-CSr8KVlo.eot +0 -0
- package/dist/web/assets/materialdesignicons-webfont-Dp5v-WZN.woff2 +0 -0
- package/dist/web/assets/materialdesignicons-webfont-PXm3-2wK.woff +0 -0
- package/dist/web/index.html +19 -0
- package/package.json +77 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Implements owner authentication and the OAuth Authorization Code + PKCE flow used for remote MCP access.
|
|
3
|
+
* Raw authorization codes and access tokens are never persisted; repositories receive only their hashes.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual, } from "node:crypto";
|
|
6
|
+
import { ChatRoomError } from "../core/errors/chatroom-error.js";
|
|
7
|
+
export class AuthService {
|
|
8
|
+
repository;
|
|
9
|
+
sessions;
|
|
10
|
+
config;
|
|
11
|
+
constructor(repository, sessions, config) {
|
|
12
|
+
this.repository = repository;
|
|
13
|
+
this.sessions = sessions;
|
|
14
|
+
this.config = config;
|
|
15
|
+
const now = new Date().toISOString();
|
|
16
|
+
repository.prune(now);
|
|
17
|
+
sessions.prune(now);
|
|
18
|
+
}
|
|
19
|
+
verifyOwnerToken(value) {
|
|
20
|
+
if (!this.config.ownerToken)
|
|
21
|
+
return false;
|
|
22
|
+
// Hash to fixed-length inputs before timingSafeEqual so differing token lengths do not short-circuit comparison.
|
|
23
|
+
return safeEqual(digest(value), digest(this.config.ownerToken));
|
|
24
|
+
}
|
|
25
|
+
registerClient(name, redirectUris) {
|
|
26
|
+
if (!name || redirectUris.length === 0)
|
|
27
|
+
throw new ChatRoomError("INVALID_INPUT", "client_name and redirect_uris are required");
|
|
28
|
+
for (const redirect of redirectUris)
|
|
29
|
+
this.validateRedirect(redirect);
|
|
30
|
+
const clientId = `client_${randomUUID()}`;
|
|
31
|
+
this.repository.createClient({
|
|
32
|
+
clientId,
|
|
33
|
+
name,
|
|
34
|
+
redirectUris,
|
|
35
|
+
createdAt: new Date().toISOString(),
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
client_id: clientId,
|
|
39
|
+
client_name: name,
|
|
40
|
+
redirect_uris: redirectUris,
|
|
41
|
+
token_endpoint_auth_method: "none",
|
|
42
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
43
|
+
response_types: ["code"],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
validateAuthorizationRequest(request) {
|
|
47
|
+
const client = this.repository.getClient(request.clientId);
|
|
48
|
+
if (!client)
|
|
49
|
+
throw new ChatRoomError("INVALID_INPUT", "Unknown OAuth client_id");
|
|
50
|
+
if (!client.redirectUris.includes(request.redirectUri))
|
|
51
|
+
throw new ChatRoomError("FORBIDDEN", "redirect_uri is not registered for this client");
|
|
52
|
+
this.validateRedirect(request.redirectUri);
|
|
53
|
+
// Only S256 is supported; plain PKCE would expose the verifier-equivalent value in the authorization request.
|
|
54
|
+
if (request.codeChallengeMethod !== "S256" ||
|
|
55
|
+
!/^[A-Za-z0-9_-]{43,128}$/.test(request.codeChallenge)) {
|
|
56
|
+
throw new ChatRoomError("INVALID_INPUT", "PKCE S256 is required");
|
|
57
|
+
}
|
|
58
|
+
const supported = new Set(["mcp"]);
|
|
59
|
+
if (!request.scopes.includes("mcp") ||
|
|
60
|
+
request.scopes.some((scope) => !supported.has(scope))) {
|
|
61
|
+
throw new ChatRoomError("INVALID_INPUT", "Unsupported OAuth scope");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
approveAuthorization(request, providedOwnerToken) {
|
|
65
|
+
this.repository.prune(new Date().toISOString());
|
|
66
|
+
this.validateAuthorizationRequest(request);
|
|
67
|
+
if (!this.verifyOwnerToken(providedOwnerToken))
|
|
68
|
+
throw new ChatRoomError("FORBIDDEN", "Owner token is invalid");
|
|
69
|
+
const code = randomBytes(32).toString("base64url");
|
|
70
|
+
this.repository.createCode({
|
|
71
|
+
codeHash: digest(code),
|
|
72
|
+
clientId: request.clientId,
|
|
73
|
+
redirectUri: request.redirectUri,
|
|
74
|
+
codeChallenge: request.codeChallenge,
|
|
75
|
+
scope: request.scopes.join(" "),
|
|
76
|
+
expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
|
77
|
+
usedAt: null,
|
|
78
|
+
});
|
|
79
|
+
return code;
|
|
80
|
+
}
|
|
81
|
+
exchangeCode(input) {
|
|
82
|
+
if (!/^[A-Za-z0-9._~-]{43,128}$/.test(input.codeVerifier))
|
|
83
|
+
throw new ChatRoomError("INVALID_INPUT", "Invalid PKCE code_verifier");
|
|
84
|
+
const codeHash = digest(input.code);
|
|
85
|
+
const candidate = this.repository.getCode(codeHash);
|
|
86
|
+
if (!candidate ||
|
|
87
|
+
candidate.clientId !== input.clientId ||
|
|
88
|
+
candidate.redirectUri !== input.redirectUri ||
|
|
89
|
+
Date.parse(candidate.expiresAt) <= Date.now()) {
|
|
90
|
+
throw new ChatRoomError("FORBIDDEN", "Authorization code is invalid or expired");
|
|
91
|
+
}
|
|
92
|
+
const challenge = createHash("sha256")
|
|
93
|
+
.update(input.codeVerifier)
|
|
94
|
+
.digest("base64url");
|
|
95
|
+
if (!safeEqual(challenge, candidate.codeChallenge))
|
|
96
|
+
throw new ChatRoomError("FORBIDDEN", "PKCE verification failed");
|
|
97
|
+
// Consume only after all client, redirect, expiry, and PKCE checks pass so a bad verifier cannot burn the code.
|
|
98
|
+
const code = this.repository.consumeCode(codeHash, new Date().toISOString());
|
|
99
|
+
if (!code)
|
|
100
|
+
throw new ChatRoomError("FORBIDDEN", "Authorization code has already been used");
|
|
101
|
+
return this.issueOAuthTokens(input.clientId, candidate.scope);
|
|
102
|
+
}
|
|
103
|
+
exchangeRefreshToken(input) {
|
|
104
|
+
this.repository.prune(new Date().toISOString());
|
|
105
|
+
const consumed = this.repository.consumeRefreshToken(digest(input.refreshToken), input.clientId);
|
|
106
|
+
if (!consumed)
|
|
107
|
+
throw new ChatRoomError("FORBIDDEN", "Refresh token is invalid or expired");
|
|
108
|
+
return this.issueOAuthTokens(consumed.clientId, consumed.scope);
|
|
109
|
+
}
|
|
110
|
+
createWebSession(providedOwnerToken, remember = true) {
|
|
111
|
+
if (!this.verifyOwnerToken(providedOwnerToken))
|
|
112
|
+
throw new ChatRoomError("FORBIDDEN", "Owner token is invalid");
|
|
113
|
+
return this.issueWebSession(remember);
|
|
114
|
+
}
|
|
115
|
+
createPasskeyWebSession(remember = true) {
|
|
116
|
+
return this.issueWebSession(remember);
|
|
117
|
+
}
|
|
118
|
+
issueWebSession(remember) {
|
|
119
|
+
this.sessions.prune(new Date().toISOString());
|
|
120
|
+
const lifetimeSeconds = remember ? 30 * 24 * 60 * 60 : 8 * 60 * 60;
|
|
121
|
+
const token = randomBytes(32).toString("base64url");
|
|
122
|
+
const expiresAt = new Date(Date.now() + lifetimeSeconds * 1000).toISOString();
|
|
123
|
+
this.sessions.create(digest(token), expiresAt);
|
|
124
|
+
return {
|
|
125
|
+
token,
|
|
126
|
+
expiresAt,
|
|
127
|
+
maxAgeSeconds: remember ? lifetimeSeconds : null,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
verifyWebSession(token) {
|
|
131
|
+
return this.sessions.valid(digest(token), new Date().toISOString());
|
|
132
|
+
}
|
|
133
|
+
revokeWebSession(token) {
|
|
134
|
+
this.sessions.revoke(digest(token));
|
|
135
|
+
}
|
|
136
|
+
verifyToken(token, requiredScope) {
|
|
137
|
+
const info = this.repository.tokenInfo(digest(token));
|
|
138
|
+
if (!info)
|
|
139
|
+
return null;
|
|
140
|
+
if (requiredScope && !info.scope.split(/\s+/).includes(requiredScope))
|
|
141
|
+
return null;
|
|
142
|
+
return info;
|
|
143
|
+
}
|
|
144
|
+
verifyMcpToken(token) {
|
|
145
|
+
const info = this.verifyToken(token, "mcp");
|
|
146
|
+
if (!info)
|
|
147
|
+
return null;
|
|
148
|
+
return {
|
|
149
|
+
token,
|
|
150
|
+
clientId: info.clientId,
|
|
151
|
+
scopes: info.scope.split(/\s+/),
|
|
152
|
+
expiresAt: Math.floor(Date.parse(info.expiresAt) / 1000),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
revoke(token) {
|
|
156
|
+
this.repository.revokeToken(digest(token));
|
|
157
|
+
}
|
|
158
|
+
authorizationServerMetadata(baseUrl) {
|
|
159
|
+
return {
|
|
160
|
+
issuer: baseUrl,
|
|
161
|
+
authorization_endpoint: `${baseUrl}/oauth/authorize`,
|
|
162
|
+
token_endpoint: `${baseUrl}/oauth/token`,
|
|
163
|
+
registration_endpoint: `${baseUrl}/oauth/register`,
|
|
164
|
+
revocation_endpoint: `${baseUrl}/oauth/revoke`,
|
|
165
|
+
response_types_supported: ["code"],
|
|
166
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
167
|
+
code_challenge_methods_supported: ["S256"],
|
|
168
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
169
|
+
scopes_supported: ["mcp"],
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
protectedResourceMetadata(baseUrl) {
|
|
173
|
+
return {
|
|
174
|
+
resource: `${baseUrl}/mcp`,
|
|
175
|
+
authorization_servers: [baseUrl],
|
|
176
|
+
scopes_supported: ["mcp"],
|
|
177
|
+
bearer_methods_supported: ["header"],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
issueAccessToken(clientId, scope, seconds) {
|
|
181
|
+
const token = randomBytes(32).toString("base64url");
|
|
182
|
+
this.repository.createToken(digest(token), clientId, scope, new Date(Date.now() + seconds * 1000).toISOString());
|
|
183
|
+
return {
|
|
184
|
+
access_token: token,
|
|
185
|
+
token_type: "Bearer",
|
|
186
|
+
expires_in: seconds,
|
|
187
|
+
scope,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
issueOAuthTokens(clientId, scope) {
|
|
191
|
+
this.repository.prune(new Date().toISOString());
|
|
192
|
+
const access = this.issueAccessToken(clientId, scope, 60 * 60);
|
|
193
|
+
const refreshToken = randomBytes(48).toString("base64url");
|
|
194
|
+
this.repository.createRefreshToken(digest(refreshToken), clientId, scope, new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString());
|
|
195
|
+
return { ...access, refresh_token: refreshToken };
|
|
196
|
+
}
|
|
197
|
+
validateRedirect(value) {
|
|
198
|
+
let url;
|
|
199
|
+
try {
|
|
200
|
+
url = new URL(value);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
throw new ChatRoomError("INVALID_INPUT", "Invalid redirect_uri");
|
|
204
|
+
}
|
|
205
|
+
// OAuth permits HTTP only for loopback development redirects; all remote redirects must use HTTPS.
|
|
206
|
+
const loopback = url.hostname === "localhost" ||
|
|
207
|
+
url.hostname === "127.0.0.1" ||
|
|
208
|
+
url.hostname === "::1";
|
|
209
|
+
if (url.protocol !== "https:" && !loopback)
|
|
210
|
+
throw new ChatRoomError("FORBIDDEN", "OAuth redirects must use HTTPS unless loopback");
|
|
211
|
+
if (!this.config.allowedRedirectHosts.includes(url.hostname))
|
|
212
|
+
throw new ChatRoomError("FORBIDDEN", `OAuth redirect host is not allowed: ${url.hostname}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function digest(value) {
|
|
216
|
+
return createHash("sha256").update(value).digest("hex");
|
|
217
|
+
}
|
|
218
|
+
function safeEqual(a, b) {
|
|
219
|
+
const left = Buffer.from(a);
|
|
220
|
+
const right = Buffer.from(b);
|
|
221
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
222
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Request } from "express";
|
|
2
|
+
import type { ChatRoomConfig } from "../config/types.js";
|
|
3
|
+
import type { ExternalAccessRegistry } from "../app/external-access-registry.js";
|
|
4
|
+
export interface WebAuthnOrigin {
|
|
5
|
+
origin: string;
|
|
6
|
+
rpId: string;
|
|
7
|
+
}
|
|
8
|
+
/** Classifies ingress exclusively from configured/registered public hosts. */
|
|
9
|
+
export declare class IngressPolicy {
|
|
10
|
+
private readonly config;
|
|
11
|
+
private readonly externalAccess;
|
|
12
|
+
constructor(config: ChatRoomConfig, externalAccess: ExternalAccessRegistry);
|
|
13
|
+
allowedHosts(): ReadonlySet<string>;
|
|
14
|
+
requiresWebAuth(req: Request): boolean;
|
|
15
|
+
requiresMcpAuth(req: Request): boolean;
|
|
16
|
+
secureWebCookie(req: Request): boolean;
|
|
17
|
+
expectedWebOrigin(req: Request): string | null;
|
|
18
|
+
webAuthnOrigin(req: Request): WebAuthnOrigin | null;
|
|
19
|
+
mcpBaseUrl(req: Request): string;
|
|
20
|
+
private externalBaseUrl;
|
|
21
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Classifies ingress exclusively from configured/registered public hosts. */
|
|
2
|
+
export class IngressPolicy {
|
|
3
|
+
config;
|
|
4
|
+
externalAccess;
|
|
5
|
+
constructor(config, externalAccess) {
|
|
6
|
+
this.config = config;
|
|
7
|
+
this.externalAccess = externalAccess;
|
|
8
|
+
}
|
|
9
|
+
allowedHosts() {
|
|
10
|
+
const allowed = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
11
|
+
if (this.config.server.host !== "0.0.0.0" &&
|
|
12
|
+
this.config.server.host !== "::")
|
|
13
|
+
allowed.add(this.config.server.host);
|
|
14
|
+
for (const host of this.externalAccess.hosts())
|
|
15
|
+
allowed.add(host);
|
|
16
|
+
return allowed;
|
|
17
|
+
}
|
|
18
|
+
requiresWebAuth(req) {
|
|
19
|
+
if (this.externalAccess.matches("web", req.hostname))
|
|
20
|
+
return true;
|
|
21
|
+
return this.config.auth.localWebAuth;
|
|
22
|
+
}
|
|
23
|
+
requiresMcpAuth(req) {
|
|
24
|
+
return this.externalAccess.matches("mcp", req.hostname);
|
|
25
|
+
}
|
|
26
|
+
secureWebCookie(req) {
|
|
27
|
+
const base = this.externalBaseUrl(req, "web");
|
|
28
|
+
if (!base)
|
|
29
|
+
return req.secure;
|
|
30
|
+
return new URL(base).protocol === "https:";
|
|
31
|
+
}
|
|
32
|
+
expectedWebOrigin(req) {
|
|
33
|
+
const base = this.externalBaseUrl(req, "web");
|
|
34
|
+
return base ? new URL(base).origin : null;
|
|
35
|
+
}
|
|
36
|
+
webAuthnOrigin(req) {
|
|
37
|
+
const base = this.externalBaseUrl(req, "web");
|
|
38
|
+
if (!base)
|
|
39
|
+
return null;
|
|
40
|
+
const url = new URL(base);
|
|
41
|
+
const loopback = url.hostname === "localhost" ||
|
|
42
|
+
url.hostname === "127.0.0.1" ||
|
|
43
|
+
url.hostname === "::1";
|
|
44
|
+
if (url.protocol !== "https:" && !loopback)
|
|
45
|
+
return null;
|
|
46
|
+
return { origin: url.origin, rpId: url.hostname };
|
|
47
|
+
}
|
|
48
|
+
mcpBaseUrl(req) {
|
|
49
|
+
const external = this.externalBaseUrl(req, "mcp");
|
|
50
|
+
if (external)
|
|
51
|
+
return external;
|
|
52
|
+
return `${req.protocol}://${req.get("host") ?? `${this.config.server.host}:${this.config.server.port}`}`.replace(/\/$/, "");
|
|
53
|
+
}
|
|
54
|
+
externalBaseUrl(req, kind) {
|
|
55
|
+
return this.externalAccess.baseUrlForHost(kind, req.hostname);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type AuthenticationResponseJSON, type RegistrationResponseJSON } from "@simplewebauthn/server";
|
|
2
|
+
import type { PasskeyRepository } from "../core/auth/passkey-repository.js";
|
|
3
|
+
import type { WebAuthnOrigin } from "./ingress-policy.js";
|
|
4
|
+
export declare class PasskeyService {
|
|
5
|
+
private readonly repository;
|
|
6
|
+
private readonly challenges;
|
|
7
|
+
constructor(repository: PasskeyRepository);
|
|
8
|
+
list(): {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
lastUsedAt: string;
|
|
12
|
+
}[];
|
|
13
|
+
remove(credentialId: string): void;
|
|
14
|
+
registrationOptions(origin: WebAuthnOrigin | null): Promise<{
|
|
15
|
+
challengeId: string;
|
|
16
|
+
options: import("@simplewebauthn/server").PublicKeyCredentialCreationOptionsJSON;
|
|
17
|
+
}>;
|
|
18
|
+
verifyRegistration(input: {
|
|
19
|
+
challengeId: string;
|
|
20
|
+
response: RegistrationResponseJSON;
|
|
21
|
+
name?: string;
|
|
22
|
+
}): Promise<{
|
|
23
|
+
registered: boolean;
|
|
24
|
+
credentialId: string;
|
|
25
|
+
}>;
|
|
26
|
+
authenticationOptions(origin: WebAuthnOrigin | null): Promise<{
|
|
27
|
+
challengeId: string;
|
|
28
|
+
options: import("@simplewebauthn/server").PublicKeyCredentialRequestOptionsJSON;
|
|
29
|
+
}>;
|
|
30
|
+
verifyAuthentication(input: {
|
|
31
|
+
challengeId: string;
|
|
32
|
+
response: AuthenticationResponseJSON;
|
|
33
|
+
}): Promise<{
|
|
34
|
+
credentialId: string;
|
|
35
|
+
}>;
|
|
36
|
+
private storeChallenge;
|
|
37
|
+
private consumeChallenge;
|
|
38
|
+
private pruneChallenges;
|
|
39
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from "@simplewebauthn/server";
|
|
3
|
+
import { ChatRoomError } from "../core/errors/chatroom-error.js";
|
|
4
|
+
export class PasskeyService {
|
|
5
|
+
repository;
|
|
6
|
+
challenges = new Map();
|
|
7
|
+
constructor(repository) {
|
|
8
|
+
this.repository = repository;
|
|
9
|
+
}
|
|
10
|
+
list() {
|
|
11
|
+
return this.repository.list().map((item) => ({
|
|
12
|
+
id: item.credentialId,
|
|
13
|
+
name: item.name,
|
|
14
|
+
lastUsedAt: item.lastUsedAt,
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
remove(credentialId) {
|
|
18
|
+
this.repository.remove(credentialId);
|
|
19
|
+
}
|
|
20
|
+
async registrationOptions(origin) {
|
|
21
|
+
const expected = requireOrigin(origin);
|
|
22
|
+
const passkeys = this.repository.list();
|
|
23
|
+
const options = await generateRegistrationOptions({
|
|
24
|
+
rpName: "ChatRoom",
|
|
25
|
+
rpID: expected.rpId,
|
|
26
|
+
userName: "chatroom-owner",
|
|
27
|
+
userDisplayName: "ChatRoom Owner",
|
|
28
|
+
userID: Buffer.from("chatroom-owner"),
|
|
29
|
+
attestationType: "none",
|
|
30
|
+
excludeCredentials: passkeys.map((item) => ({
|
|
31
|
+
id: item.credentialId,
|
|
32
|
+
transports: item.transports,
|
|
33
|
+
})),
|
|
34
|
+
authenticatorSelection: {
|
|
35
|
+
residentKey: "required",
|
|
36
|
+
userVerification: "required",
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const challengeId = this.storeChallenge("registration", options.challenge, expected);
|
|
40
|
+
return { challengeId, options };
|
|
41
|
+
}
|
|
42
|
+
async verifyRegistration(input) {
|
|
43
|
+
const record = this.consumeChallenge(input.challengeId, "registration");
|
|
44
|
+
if (!record)
|
|
45
|
+
throw new ChatRoomError("FORBIDDEN", "Passkey challenge is invalid or expired");
|
|
46
|
+
const verification = await verifyRegistrationResponse({
|
|
47
|
+
response: input.response,
|
|
48
|
+
expectedChallenge: record.challenge,
|
|
49
|
+
expectedOrigin: record.origin,
|
|
50
|
+
expectedRPID: record.rpId,
|
|
51
|
+
requireUserVerification: true,
|
|
52
|
+
});
|
|
53
|
+
if (!verification.verified)
|
|
54
|
+
throw new ChatRoomError("FORBIDDEN", "Passkey registration could not be verified");
|
|
55
|
+
const now = new Date().toISOString();
|
|
56
|
+
const credential = verification.registrationInfo.credential;
|
|
57
|
+
this.repository.upsert({
|
|
58
|
+
credentialId: credential.id,
|
|
59
|
+
publicKey: credential.publicKey,
|
|
60
|
+
counter: credential.counter,
|
|
61
|
+
transports: credential.transports ?? input.response.response.transports ?? [],
|
|
62
|
+
deviceType: verification.registrationInfo.credentialDeviceType,
|
|
63
|
+
backedUp: verification.registrationInfo.credentialBackedUp,
|
|
64
|
+
name: input.name?.trim().slice(0, 80) || "Passkey",
|
|
65
|
+
createdAt: now,
|
|
66
|
+
lastUsedAt: now,
|
|
67
|
+
});
|
|
68
|
+
return { registered: true, credentialId: credential.id };
|
|
69
|
+
}
|
|
70
|
+
async authenticationOptions(origin) {
|
|
71
|
+
const expected = requireOrigin(origin);
|
|
72
|
+
const passkeys = this.repository.list();
|
|
73
|
+
if (!passkeys.length)
|
|
74
|
+
throw new ChatRoomError("NOT_FOUND", "No passkeys are registered");
|
|
75
|
+
const options = await generateAuthenticationOptions({
|
|
76
|
+
rpID: expected.rpId,
|
|
77
|
+
userVerification: "required",
|
|
78
|
+
});
|
|
79
|
+
const challengeId = this.storeChallenge("authentication", options.challenge, expected);
|
|
80
|
+
return { challengeId, options };
|
|
81
|
+
}
|
|
82
|
+
async verifyAuthentication(input) {
|
|
83
|
+
const record = this.consumeChallenge(input.challengeId, "authentication");
|
|
84
|
+
if (!record)
|
|
85
|
+
throw new ChatRoomError("FORBIDDEN", "Passkey challenge is invalid or expired");
|
|
86
|
+
const passkey = this.repository.get(input.response.id);
|
|
87
|
+
if (!passkey)
|
|
88
|
+
throw new ChatRoomError("FORBIDDEN", "Unknown passkey credential");
|
|
89
|
+
const publicKey = new Uint8Array(passkey.publicKey.byteLength);
|
|
90
|
+
publicKey.set(passkey.publicKey);
|
|
91
|
+
const verification = await verifyAuthenticationResponse({
|
|
92
|
+
response: input.response,
|
|
93
|
+
expectedChallenge: record.challenge,
|
|
94
|
+
expectedOrigin: record.origin,
|
|
95
|
+
expectedRPID: record.rpId,
|
|
96
|
+
credential: {
|
|
97
|
+
id: passkey.credentialId,
|
|
98
|
+
publicKey,
|
|
99
|
+
counter: passkey.counter,
|
|
100
|
+
transports: passkey.transports,
|
|
101
|
+
},
|
|
102
|
+
requireUserVerification: true,
|
|
103
|
+
});
|
|
104
|
+
if (!verification.verified)
|
|
105
|
+
throw new ChatRoomError("FORBIDDEN", "Passkey authentication could not be verified");
|
|
106
|
+
this.repository.updateCounter(passkey.credentialId, verification.authenticationInfo.newCounter);
|
|
107
|
+
return { credentialId: passkey.credentialId };
|
|
108
|
+
}
|
|
109
|
+
storeChallenge(kind, challenge, origin) {
|
|
110
|
+
this.pruneChallenges();
|
|
111
|
+
while (this.challenges.size >= 64) {
|
|
112
|
+
const oldest = this.challenges.keys().next().value;
|
|
113
|
+
if (!oldest)
|
|
114
|
+
break;
|
|
115
|
+
this.challenges.delete(oldest);
|
|
116
|
+
}
|
|
117
|
+
const id = `pkc_${randomUUID()}`;
|
|
118
|
+
this.challenges.set(id, {
|
|
119
|
+
kind,
|
|
120
|
+
challenge,
|
|
121
|
+
origin: origin.origin,
|
|
122
|
+
rpId: origin.rpId,
|
|
123
|
+
expiresAt: Date.now() + 5 * 60 * 1000,
|
|
124
|
+
});
|
|
125
|
+
return id;
|
|
126
|
+
}
|
|
127
|
+
consumeChallenge(id, kind) {
|
|
128
|
+
const record = this.challenges.get(id);
|
|
129
|
+
this.challenges.delete(id);
|
|
130
|
+
if (!record || record.kind !== kind || record.expiresAt <= Date.now())
|
|
131
|
+
return null;
|
|
132
|
+
return record;
|
|
133
|
+
}
|
|
134
|
+
pruneChallenges() {
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
for (const [id, record] of this.challenges)
|
|
137
|
+
if (record.expiresAt <= now)
|
|
138
|
+
this.challenges.delete(id);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function requireOrigin(origin) {
|
|
142
|
+
if (!origin)
|
|
143
|
+
throw new ChatRoomError("UNSUPPORTED", "Passkeys require a secure public WebUI origin");
|
|
144
|
+
return origin;
|
|
145
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { asChatRoomError } from "../core/errors/chatroom-error.js";
|
|
3
|
+
import { runChatRoomCli } from "./run-cli.js";
|
|
4
|
+
void runChatRoomCli().catch((error) => {
|
|
5
|
+
const normalized = asChatRoomError(error);
|
|
6
|
+
console.error(`[${normalized.code}] ${normalized.message}`);
|
|
7
|
+
if (normalized.details)
|
|
8
|
+
console.error(normalized.details);
|
|
9
|
+
process.exitCode = 1;
|
|
10
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runChatRoomCli(argv?: string[]): Promise<void>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** ChatRoom CLI host. */
|
|
2
|
+
import { initializeConfig, loadConfig } from "../config/load-config.js";
|
|
3
|
+
import { ApplicationLifecycle } from "../app/lifecycle.js";
|
|
4
|
+
export async function runChatRoomCli(argv = process.argv.slice(2)) {
|
|
5
|
+
const [command = "help"] = argv;
|
|
6
|
+
if (command === "init")
|
|
7
|
+
return init();
|
|
8
|
+
if (command === "serve")
|
|
9
|
+
return serve();
|
|
10
|
+
printHelp();
|
|
11
|
+
}
|
|
12
|
+
async function init() {
|
|
13
|
+
const result = await initializeConfig();
|
|
14
|
+
console.log(`ChatRoom initialized: ${result.configPath}`);
|
|
15
|
+
console.log(`Allowed roots: ${result.config.allowedRoots.join(", ")}`);
|
|
16
|
+
console.log(`Owner token: ${result.config.auth.ownerToken}`);
|
|
17
|
+
console.log("Local loopback WebUI does not require authentication by default. Keep the owner token private for public access.");
|
|
18
|
+
}
|
|
19
|
+
async function serve() {
|
|
20
|
+
const config = await loadConfig();
|
|
21
|
+
const lifecycle = new ApplicationLifecycle(config);
|
|
22
|
+
await lifecycle.start();
|
|
23
|
+
console.log(`ChatRoom listening on http://${config.server.host}:${config.server.port}`);
|
|
24
|
+
let closing = false;
|
|
25
|
+
const shutdown = () => {
|
|
26
|
+
if (closing)
|
|
27
|
+
return;
|
|
28
|
+
closing = true;
|
|
29
|
+
void lifecycle
|
|
30
|
+
.shutdown()
|
|
31
|
+
.then(() => process.exit(0))
|
|
32
|
+
.catch((error) => {
|
|
33
|
+
console.error(error);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
process.once("SIGINT", shutdown);
|
|
38
|
+
process.once("SIGTERM", shutdown);
|
|
39
|
+
}
|
|
40
|
+
function printHelp() {
|
|
41
|
+
console.log("ChatRoom\n\nUsage:\n chatroom init\n chatroom serve");
|
|
42
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ChatRoomConfig } from "./types.js";
|
|
2
|
+
export declare function defaultConfig(): ChatRoomConfig;
|
|
3
|
+
export declare function loadConfig(configPath?: string): Promise<ChatRoomConfig>;
|
|
4
|
+
export declare function initializeConfig(): Promise<{
|
|
5
|
+
config: ChatRoomConfig;
|
|
6
|
+
configPath: string;
|
|
7
|
+
}>;
|