@lifeaitools/clauth 0.5.2 → 0.7.0
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/cli/commands/serve.js +378 -18
- package/cli/index.js +2 -1
- package/package.json +1 -1
package/cli/commands/serve.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// Supports: start (background daemon), stop, restart, ping, foreground
|
|
6
6
|
|
|
7
7
|
import http from "http";
|
|
8
|
+
import crypto from "crypto";
|
|
8
9
|
import fs from "fs";
|
|
9
10
|
import os from "os";
|
|
10
11
|
import path from "path";
|
|
@@ -153,6 +154,18 @@ function dashboardHtml(port, whitelist) {
|
|
|
153
154
|
.btn-tunnel-stop{background:#1e293b;color:#f87171;border:1px solid #334155;padding:6px 12px;font-size:.8rem;border-radius:6px;cursor:pointer;font-weight:500}
|
|
154
155
|
.btn-tunnel-stop:hover{background:#2d1f1f;border-color:#f87171}
|
|
155
156
|
.tunnel-err{font-size:.78rem;color:#f87171;width:100%;margin-top:4px}
|
|
157
|
+
.mcp-setup{background:#0f1d2d;border:1px solid #1e3a5f;border-radius:8px;padding:1rem 1.25rem;margin-bottom:1.25rem;display:none}
|
|
158
|
+
.mcp-setup.open{display:block}
|
|
159
|
+
.mcp-setup-title{font-size:.85rem;font-weight:600;color:#e2e8f0;margin-bottom:.75rem}
|
|
160
|
+
.mcp-row{display:flex;align-items:center;gap:8px;margin-bottom:8px}
|
|
161
|
+
.mcp-label{font-size:.72rem;color:#64748b;min-width:80px;text-transform:uppercase;letter-spacing:.5px;font-weight:600}
|
|
162
|
+
.mcp-val{flex:1;font-family:'Courier New',monospace;font-size:.82rem;color:#60a5fa;background:#0a0f1a;border:1px solid #1e3a5f;border-radius:4px;padding:6px 10px;word-break:break-all;user-select:all}
|
|
163
|
+
.mcp-copy{background:none;border:1px solid #334155;color:#94a3b8;border-radius:4px;padding:4px 8px;cursor:pointer;font-size:.75rem;font-family:'Courier New',monospace;transition:all .15s;flex-shrink:0}
|
|
164
|
+
.mcp-copy:hover{border-color:#60a5fa;color:#60a5fa}
|
|
165
|
+
.mcp-copy.ok{border-color:#4ade80;color:#4ade80}
|
|
166
|
+
.btn-mcp-setup{background:#1e293b;color:#94a3b8;border:1px solid #334155;padding:6px 12px;font-size:.8rem;border-radius:6px;cursor:pointer;font-weight:500;transition:all .15s}
|
|
167
|
+
.btn-mcp-setup:hover{border-color:#60a5fa;color:#60a5fa}
|
|
168
|
+
.btn-mcp-setup:disabled{opacity:.4;cursor:not-allowed}
|
|
156
169
|
.footer{margin-top:2rem;font-size:.75rem;color:#475569;text-align:center}
|
|
157
170
|
.oauth-fields{display:flex;flex-direction:column;gap:8px;margin-bottom:8px}
|
|
158
171
|
.oauth-field{display:flex;flex-direction:column;gap:3px}
|
|
@@ -223,11 +236,34 @@ function dashboardHtml(port, whitelist) {
|
|
|
223
236
|
<button class="btn-check" id="btn-tunnel-test" style="display:none;padding:6px 12px;font-size:.8rem" onclick="testTunnel()">Test</button>
|
|
224
237
|
<button class="btn-claude" id="btn-claude" disabled onclick="openClaude()">Connect claude.ai</button>
|
|
225
238
|
<button class="btn-tunnel-stop" id="btn-tunnel-toggle" style="display:none" onclick="toggleTunnel()">Stop</button>
|
|
239
|
+
<button class="btn-mcp-setup" id="btn-mcp-setup" style="display:none" onclick="toggleMcpSetup()">Setup MCP</button>
|
|
226
240
|
<div class="tunnel-err" id="tunnel-err" style="display:none"></div>
|
|
227
241
|
</div>
|
|
228
242
|
|
|
243
|
+
<div class="mcp-setup" id="mcp-setup-panel">
|
|
244
|
+
<div class="mcp-setup-title">claude.ai MCP Integration</div>
|
|
245
|
+
<div class="oauth-fields">
|
|
246
|
+
<div class="mcp-row">
|
|
247
|
+
<span class="mcp-label">URL</span>
|
|
248
|
+
<span class="mcp-val" id="mcp-url">—</span>
|
|
249
|
+
<button class="mcp-copy" onclick="copyMcp('mcp-url')">copy</button>
|
|
250
|
+
</div>
|
|
251
|
+
<div class="mcp-row">
|
|
252
|
+
<span class="mcp-label">Client ID</span>
|
|
253
|
+
<span class="mcp-val" id="mcp-client-id">—</span>
|
|
254
|
+
<button class="mcp-copy" onclick="copyMcp('mcp-client-id')">copy</button>
|
|
255
|
+
</div>
|
|
256
|
+
<div class="mcp-row">
|
|
257
|
+
<span class="mcp-label">Secret</span>
|
|
258
|
+
<span class="mcp-val" id="mcp-client-secret">—</span>
|
|
259
|
+
<button class="mcp-copy" onclick="copyMcp('mcp-client-secret')">copy</button>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
<div style="font-size:.72rem;color:#64748b;margin-top:4px">Paste these into <a href="https://claude.ai/settings/integrations" target="_blank" style="color:#60a5fa">claude.ai Settings → Integrations</a></div>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
229
265
|
<div id="grid" class="grid"><p class="loading">Loading services…</p></div>
|
|
230
|
-
<div class="footer">localhost:${port} · 127.0.0.1 only ·
|
|
266
|
+
<div class="footer">localhost:${port} · 127.0.0.1 only · 10-strike lockout</div>
|
|
231
267
|
</div>
|
|
232
268
|
|
|
233
269
|
<script>
|
|
@@ -714,6 +750,7 @@ async function updateTunnelUI() {
|
|
|
714
750
|
const label = document.getElementById("tunnel-label");
|
|
715
751
|
const btn = document.getElementById("btn-claude");
|
|
716
752
|
const togBtn = document.getElementById("btn-tunnel-toggle");
|
|
753
|
+
const mcpBtn = document.getElementById("btn-mcp-setup");
|
|
717
754
|
const errEl = document.getElementById("tunnel-err");
|
|
718
755
|
|
|
719
756
|
try {
|
|
@@ -725,10 +762,12 @@ async function updateTunnelUI() {
|
|
|
725
762
|
dot.className = "tunnel-dot err";
|
|
726
763
|
label.innerHTML = '<strong>claude.ai MCP</strong> — ' + t.error;
|
|
727
764
|
btn.disabled = true;
|
|
765
|
+
mcpBtn.style.display = "none";
|
|
728
766
|
togBtn.style.display = "none";
|
|
729
767
|
togBtn.textContent = "Start Tunnel";
|
|
730
768
|
togBtn.onclick = () => toggleTunnel("start");
|
|
731
769
|
togBtn.style.display = "inline-block";
|
|
770
|
+
document.getElementById("mcp-setup-panel").classList.remove("open");
|
|
732
771
|
return "error";
|
|
733
772
|
}
|
|
734
773
|
|
|
@@ -740,6 +779,7 @@ async function updateTunnelUI() {
|
|
|
740
779
|
togBtn.textContent = "Stop Tunnel";
|
|
741
780
|
togBtn.onclick = () => toggleTunnel("stop");
|
|
742
781
|
togBtn.style.display = "inline-block";
|
|
782
|
+
mcpBtn.style.display = "inline-block";
|
|
743
783
|
return "connected";
|
|
744
784
|
}
|
|
745
785
|
|
|
@@ -747,6 +787,7 @@ async function updateTunnelUI() {
|
|
|
747
787
|
dot.className = "tunnel-dot starting";
|
|
748
788
|
label.innerHTML = '<strong>claude.ai MCP</strong> — starting tunnel…';
|
|
749
789
|
btn.disabled = true;
|
|
790
|
+
mcpBtn.style.display = "none";
|
|
750
791
|
togBtn.textContent = "Stop Tunnel";
|
|
751
792
|
togBtn.onclick = () => toggleTunnel("stop");
|
|
752
793
|
togBtn.style.display = "inline-block";
|
|
@@ -757,16 +798,20 @@ async function updateTunnelUI() {
|
|
|
757
798
|
dot.className = "tunnel-dot off";
|
|
758
799
|
label.innerHTML = '<strong>claude.ai MCP</strong> — tunnel not running';
|
|
759
800
|
btn.disabled = true;
|
|
801
|
+
mcpBtn.style.display = "none";
|
|
760
802
|
togBtn.textContent = "Start Tunnel";
|
|
761
803
|
togBtn.onclick = () => toggleTunnel("start");
|
|
762
804
|
togBtn.style.display = "inline-block";
|
|
805
|
+
document.getElementById("mcp-setup-panel").classList.remove("open");
|
|
763
806
|
return "stopped";
|
|
764
807
|
|
|
765
808
|
} catch {
|
|
766
809
|
dot.className = "tunnel-dot off";
|
|
767
810
|
label.innerHTML = '<strong>claude.ai MCP</strong> — unable to reach daemon';
|
|
768
811
|
btn.disabled = true;
|
|
812
|
+
mcpBtn.style.display = "none";
|
|
769
813
|
togBtn.style.display = "none";
|
|
814
|
+
document.getElementById("mcp-setup-panel").classList.remove("open");
|
|
770
815
|
return "error";
|
|
771
816
|
}
|
|
772
817
|
}
|
|
@@ -827,6 +872,32 @@ function openClaude() {
|
|
|
827
872
|
});
|
|
828
873
|
}
|
|
829
874
|
|
|
875
|
+
async function toggleMcpSetup() {
|
|
876
|
+
const panel = document.getElementById("mcp-setup-panel");
|
|
877
|
+
const isOpen = panel.classList.toggle("open");
|
|
878
|
+
if (isOpen) {
|
|
879
|
+
try {
|
|
880
|
+
const m = await fetch(BASE + "/mcp-setup").then(r => r.json());
|
|
881
|
+
document.getElementById("mcp-url").textContent = m.url || "(tunnel not running)";
|
|
882
|
+
document.getElementById("mcp-client-id").textContent = m.clientId || "—";
|
|
883
|
+
document.getElementById("mcp-client-secret").textContent = m.clientSecret || "—";
|
|
884
|
+
} catch {
|
|
885
|
+
document.getElementById("mcp-url").textContent = "(error fetching)";
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function copyMcp(elId) {
|
|
891
|
+
const val = document.getElementById(elId).textContent;
|
|
892
|
+
if (!val || val === "—" || val.startsWith("(")) return;
|
|
893
|
+
const btn = document.getElementById(elId).nextElementSibling;
|
|
894
|
+
navigator.clipboard.writeText(val).then(() => {
|
|
895
|
+
btn.textContent = "ok";
|
|
896
|
+
btn.classList.add("ok");
|
|
897
|
+
setTimeout(() => { btn.textContent = "copy"; btn.classList.remove("ok"); }, 1500);
|
|
898
|
+
}).catch(() => {});
|
|
899
|
+
}
|
|
900
|
+
|
|
830
901
|
boot();
|
|
831
902
|
</script>
|
|
832
903
|
</body>
|
|
@@ -844,7 +915,7 @@ function readBody(req) {
|
|
|
844
915
|
}
|
|
845
916
|
|
|
846
917
|
// ── Server logic (shared by foreground + daemon) ─────────────
|
|
847
|
-
function createServer(initPassword, whitelist, port) {
|
|
918
|
+
function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
848
919
|
// Ensure Windows system tools are reachable (bash shells may lack these on PATH)
|
|
849
920
|
if (os.platform() === "win32") {
|
|
850
921
|
const sys32 = "C:\\Windows\\System32";
|
|
@@ -855,7 +926,7 @@ function createServer(initPassword, whitelist, port) {
|
|
|
855
926
|
process.env.PATH = (process.env.PATH || "") + ";" + sys32;
|
|
856
927
|
}
|
|
857
928
|
}
|
|
858
|
-
const MAX_FAILS =
|
|
929
|
+
const MAX_FAILS = 10;
|
|
859
930
|
let failCount = 0;
|
|
860
931
|
let password = initPassword || null; // null = locked; set via POST /auth
|
|
861
932
|
const machineHash = getMachineHash();
|
|
@@ -863,7 +934,7 @@ function createServer(initPassword, whitelist, port) {
|
|
|
863
934
|
const CORS = {
|
|
864
935
|
"Access-Control-Allow-Origin": "*",
|
|
865
936
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
866
|
-
"Access-Control-Allow-Headers": "Content-Type",
|
|
937
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, Mcp-Session-Id",
|
|
867
938
|
};
|
|
868
939
|
|
|
869
940
|
// ── MCP SSE session tracking ──────────────────────────────
|
|
@@ -891,13 +962,39 @@ function createServer(initPassword, whitelist, port) {
|
|
|
891
962
|
let tunnelUrl = null;
|
|
892
963
|
let tunnelError = null;
|
|
893
964
|
|
|
965
|
+
// ── OAuth provider (self-contained for claude.ai MCP) ──────
|
|
966
|
+
const oauthClients = new Map(); // client_id → { client_secret, redirect_uris, client_name }
|
|
967
|
+
const oauthCodes = new Map(); // code → { client_id, redirect_uri, code_challenge, expires }
|
|
968
|
+
const oauthTokens = new Set(); // active access tokens
|
|
969
|
+
|
|
970
|
+
// Pre-generate a stable client for claude.ai (shown at startup)
|
|
971
|
+
const OAUTH_CLIENT_ID = crypto.randomBytes(16).toString("hex");
|
|
972
|
+
const OAUTH_CLIENT_SECRET = crypto.randomBytes(32).toString("hex");
|
|
973
|
+
oauthClients.set(OAUTH_CLIENT_ID, {
|
|
974
|
+
client_id: OAUTH_CLIENT_ID, client_secret: OAUTH_CLIENT_SECRET,
|
|
975
|
+
client_name: "claude.ai", redirect_uris: ["https://claude.ai/api/mcp/auth_callback"],
|
|
976
|
+
grant_types: ["authorization_code"], response_types: ["code"],
|
|
977
|
+
token_endpoint_auth_method: "client_secret_post",
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
function oauthBase() { return tunnelUrl || `http://127.0.0.1:${port}`; }
|
|
981
|
+
function sha256base64url(str) { return crypto.createHash("sha256").update(str).digest("base64url"); }
|
|
982
|
+
|
|
983
|
+
function readRawBody(req) {
|
|
984
|
+
return new Promise((resolve, reject) => {
|
|
985
|
+
let data = "";
|
|
986
|
+
req.on("data", chunk => { data += chunk; if (data.length > 65536) reject(new Error("Body too large")); });
|
|
987
|
+
req.on("end", () => resolve(data));
|
|
988
|
+
req.on("error", reject);
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
|
|
894
992
|
async function startTunnel() {
|
|
895
993
|
if (tunnelProc) return; // already running
|
|
896
994
|
tunnelUrl = null;
|
|
897
995
|
tunnelError = null;
|
|
898
996
|
|
|
899
997
|
try {
|
|
900
|
-
// Quick tunnel — no DNS/config needed, new random URL each session
|
|
901
998
|
// Resolve cloudflared binary — may not be on PATH in bash shells
|
|
902
999
|
let cfBin = "cloudflared";
|
|
903
1000
|
if (os.platform() === "win32") {
|
|
@@ -913,17 +1010,30 @@ function createServer(initPassword, whitelist, port) {
|
|
|
913
1010
|
}
|
|
914
1011
|
}
|
|
915
1012
|
|
|
916
|
-
|
|
1013
|
+
// Named tunnel (fixed subdomain) or quick tunnel (random URL)
|
|
1014
|
+
let args;
|
|
1015
|
+
if (tunnelHostname) {
|
|
1016
|
+
// Named tunnel: cloudflared tunnel run (uses ~/.cloudflared/config.yml)
|
|
1017
|
+
// Config maps hostname → local service, so no --url needed
|
|
1018
|
+
args = ["tunnel", "run"];
|
|
1019
|
+
tunnelUrl = `https://${tunnelHostname}`;
|
|
1020
|
+
} else {
|
|
1021
|
+
// Quick tunnel — random URL each session
|
|
1022
|
+
args = ["tunnel", "--url", `http://127.0.0.1:${port}`];
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const proc = spawnProc(cfBin, args, {
|
|
917
1026
|
stdio: ["ignore", "pipe", "pipe"],
|
|
918
1027
|
});
|
|
919
1028
|
|
|
920
1029
|
tunnelProc = proc;
|
|
921
1030
|
|
|
922
|
-
// cloudflared prints
|
|
1031
|
+
// cloudflared prints output to stderr
|
|
923
1032
|
let stderrBuf = "";
|
|
924
1033
|
proc.stderr.on("data", (chunk) => {
|
|
925
1034
|
stderrBuf += chunk.toString();
|
|
926
|
-
|
|
1035
|
+
// For quick tunnels, capture the random URL from stderr
|
|
1036
|
+
if (!tunnelHostname && !tunnelUrl) {
|
|
927
1037
|
const match = stderrBuf.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
928
1038
|
if (match) {
|
|
929
1039
|
tunnelUrl = match[0];
|
|
@@ -954,6 +1064,11 @@ function createServer(initPassword, whitelist, port) {
|
|
|
954
1064
|
// Give it a moment to start
|
|
955
1065
|
await new Promise(r => setTimeout(r, 4000));
|
|
956
1066
|
|
|
1067
|
+
if (tunnelHostname && tunnelProc) {
|
|
1068
|
+
const logLine = `[${new Date().toISOString()}] Named tunnel started: ${tunnelUrl}\n`;
|
|
1069
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
957
1072
|
} catch (err) {
|
|
958
1073
|
tunnelError = err.message;
|
|
959
1074
|
tunnelProc = null;
|
|
@@ -1006,9 +1121,18 @@ function createServer(initPassword, whitelist, port) {
|
|
|
1006
1121
|
}
|
|
1007
1122
|
|
|
1008
1123
|
const server = http.createServer(async (req, res) => {
|
|
1009
|
-
// Hard reject anything not from loopback
|
|
1010
1124
|
const remote = req.socket.remoteAddress;
|
|
1011
1125
|
const isLocal = remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
|
|
1126
|
+
|
|
1127
|
+
const url = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
1128
|
+
const reqPath = url.pathname;
|
|
1129
|
+
const method = req.method;
|
|
1130
|
+
|
|
1131
|
+
// Log every request
|
|
1132
|
+
const logLine = `[${new Date().toISOString()}] ${method} ${reqPath} from=${remote} local=${isLocal}\n`;
|
|
1133
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
1134
|
+
|
|
1135
|
+
// Hard reject anything not from loopback
|
|
1012
1136
|
if (!isLocal) {
|
|
1013
1137
|
return strike(res, 403, `Rejected non-local address: ${remote}`);
|
|
1014
1138
|
}
|
|
@@ -1019,11 +1143,213 @@ function createServer(initPassword, whitelist, port) {
|
|
|
1019
1143
|
return res.end();
|
|
1020
1144
|
}
|
|
1021
1145
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1146
|
+
// ── OAuth Discovery (RFC 9728 + RFC 8414) ──────────────
|
|
1147
|
+
if (reqPath === "/.well-known/oauth-protected-resource" ||
|
|
1148
|
+
reqPath === "/.well-known/oauth-protected-resource/mcp" ||
|
|
1149
|
+
reqPath === "/.well-known/oauth-protected-resource/sse") {
|
|
1150
|
+
const base = oauthBase();
|
|
1151
|
+
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
1152
|
+
return res.end(JSON.stringify({
|
|
1153
|
+
resource: `${base}/mcp`,
|
|
1154
|
+
authorization_servers: [base],
|
|
1155
|
+
scopes_supported: ["mcp:tools"],
|
|
1156
|
+
bearer_methods_supported: ["header"],
|
|
1157
|
+
}));
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
if (reqPath === "/.well-known/oauth-authorization-server") {
|
|
1161
|
+
const base = oauthBase();
|
|
1162
|
+
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
1163
|
+
return res.end(JSON.stringify({
|
|
1164
|
+
issuer: base,
|
|
1165
|
+
authorization_endpoint: `${base}/authorize`,
|
|
1166
|
+
token_endpoint: `${base}/token`,
|
|
1167
|
+
registration_endpoint: `${base}/register`,
|
|
1168
|
+
response_types_supported: ["code"],
|
|
1169
|
+
grant_types_supported: ["authorization_code"],
|
|
1170
|
+
code_challenge_methods_supported: ["S256"],
|
|
1171
|
+
scopes_supported: ["mcp:tools"],
|
|
1172
|
+
}));
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// ── Dynamic Client Registration (RFC 7591) ──────────────
|
|
1176
|
+
if (method === "POST" && reqPath === "/register") {
|
|
1177
|
+
let body;
|
|
1178
|
+
try { body = await readBody(req); } catch {
|
|
1179
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
1180
|
+
return res.end(JSON.stringify({ error: "invalid_request" }));
|
|
1181
|
+
}
|
|
1182
|
+
const clientId = crypto.randomBytes(16).toString("hex");
|
|
1183
|
+
const clientSecret = crypto.randomBytes(32).toString("hex");
|
|
1184
|
+
const client = {
|
|
1185
|
+
client_id: clientId, client_secret: clientSecret,
|
|
1186
|
+
client_name: body.client_name || "unknown",
|
|
1187
|
+
redirect_uris: body.redirect_uris || [],
|
|
1188
|
+
grant_types: body.grant_types || ["authorization_code"],
|
|
1189
|
+
response_types: body.response_types || ["code"],
|
|
1190
|
+
token_endpoint_auth_method: body.token_endpoint_auth_method || "client_secret_post",
|
|
1191
|
+
};
|
|
1192
|
+
oauthClients.set(clientId, client);
|
|
1193
|
+
const logMsg = `[${new Date().toISOString()}] OAuth: registered client ${clientId} (${client.client_name})\n`;
|
|
1194
|
+
try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
|
|
1195
|
+
res.writeHead(201, { "Content-Type": "application/json", ...CORS });
|
|
1196
|
+
return res.end(JSON.stringify(client));
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// ── Authorization endpoint — auto-approve ──────────────
|
|
1200
|
+
if (method === "GET" && reqPath === "/authorize") {
|
|
1201
|
+
const clientId = url.searchParams.get("client_id");
|
|
1202
|
+
const redirectUri = url.searchParams.get("redirect_uri");
|
|
1203
|
+
const state = url.searchParams.get("state");
|
|
1204
|
+
const codeChallenge = url.searchParams.get("code_challenge");
|
|
1205
|
+
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
|
|
1206
|
+
|
|
1207
|
+
if (!clientId || !redirectUri) {
|
|
1208
|
+
res.writeHead(400, { "Content-Type": "text/plain", ...CORS });
|
|
1209
|
+
return res.end("Missing client_id or redirect_uri");
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
const code = crypto.randomBytes(32).toString("hex");
|
|
1213
|
+
oauthCodes.set(code, {
|
|
1214
|
+
client_id: clientId, redirect_uri: redirectUri,
|
|
1215
|
+
code_challenge: codeChallenge, code_challenge_method: codeChallengeMethod,
|
|
1216
|
+
expires: Date.now() + 300_000,
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
const redirect = new URL(redirectUri);
|
|
1220
|
+
redirect.searchParams.set("code", code);
|
|
1221
|
+
if (state) redirect.searchParams.set("state", state);
|
|
1222
|
+
|
|
1223
|
+
const logMsg = `[${new Date().toISOString()}] OAuth: authorize → code issued for ${clientId}, redirecting to ${redirect.origin}\n`;
|
|
1224
|
+
try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
|
|
1225
|
+
res.writeHead(302, { Location: redirect.toString(), ...CORS });
|
|
1226
|
+
return res.end();
|
|
1227
|
+
}
|
|
1025
1228
|
|
|
1026
|
-
// ──
|
|
1229
|
+
// ── Token endpoint ──────────────────────────────────────
|
|
1230
|
+
if (method === "POST" && reqPath === "/token") {
|
|
1231
|
+
let body;
|
|
1232
|
+
const ct = req.headers["content-type"] || "";
|
|
1233
|
+
try {
|
|
1234
|
+
if (ct.includes("application/json")) {
|
|
1235
|
+
body = await readBody(req);
|
|
1236
|
+
} else {
|
|
1237
|
+
const raw = await readRawBody(req);
|
|
1238
|
+
body = Object.fromEntries(new URLSearchParams(raw));
|
|
1239
|
+
}
|
|
1240
|
+
} catch {
|
|
1241
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
1242
|
+
return res.end(JSON.stringify({ error: "invalid_request" }));
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
if (body.grant_type !== "authorization_code") {
|
|
1246
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
1247
|
+
return res.end(JSON.stringify({ error: "unsupported_grant_type" }));
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
const stored = oauthCodes.get(body.code);
|
|
1251
|
+
if (!stored || stored.expires < Date.now()) {
|
|
1252
|
+
oauthCodes.delete(body.code);
|
|
1253
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
1254
|
+
return res.end(JSON.stringify({ error: "invalid_grant" }));
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// PKCE verification
|
|
1258
|
+
if (stored.code_challenge && body.code_verifier) {
|
|
1259
|
+
const computed = sha256base64url(body.code_verifier);
|
|
1260
|
+
if (computed !== stored.code_challenge) {
|
|
1261
|
+
oauthCodes.delete(body.code);
|
|
1262
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
1263
|
+
return res.end(JSON.stringify({ error: "invalid_grant", error_description: "PKCE verification failed" }));
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
oauthCodes.delete(body.code);
|
|
1268
|
+
const accessToken = crypto.randomBytes(32).toString("hex");
|
|
1269
|
+
oauthTokens.add(accessToken);
|
|
1270
|
+
|
|
1271
|
+
const logMsg = `[${new Date().toISOString()}] OAuth: token issued for client ${stored.client_id}\n`;
|
|
1272
|
+
try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
|
|
1273
|
+
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
1274
|
+
return res.end(JSON.stringify({ access_token: accessToken, token_type: "Bearer", expires_in: 86400 }));
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// ── MCP OAuth-protected endpoint (for claude.ai web) ──
|
|
1278
|
+
// POST /mcp — requires Bearer token; returns 401 to trigger OAuth flow
|
|
1279
|
+
if (method === "POST" && reqPath === "/mcp") {
|
|
1280
|
+
const authHeader = req.headers.authorization;
|
|
1281
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
1282
|
+
const base = oauthBase();
|
|
1283
|
+
res.writeHead(401, {
|
|
1284
|
+
"Content-Type": "application/json",
|
|
1285
|
+
"WWW-Authenticate": `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource"`,
|
|
1286
|
+
...CORS,
|
|
1287
|
+
});
|
|
1288
|
+
return res.end(JSON.stringify({ error: "unauthorized" }));
|
|
1289
|
+
}
|
|
1290
|
+
const token = authHeader.slice(7);
|
|
1291
|
+
if (!oauthTokens.has(token)) {
|
|
1292
|
+
res.writeHead(401, { "Content-Type": "application/json", ...CORS });
|
|
1293
|
+
return res.end(JSON.stringify({ error: "invalid_token" }));
|
|
1294
|
+
}
|
|
1295
|
+
// Token valid — fall through to MCP handling below
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// ── MCP Streamable HTTP transport (2025-03-26 spec) ──
|
|
1299
|
+
// POST /sse or POST /mcp — JSON-RPC over HTTP
|
|
1300
|
+
if (method === "POST" && (reqPath === "/sse" || reqPath === "/mcp")) {
|
|
1301
|
+
let body;
|
|
1302
|
+
try { body = await readBody(req); } catch {
|
|
1303
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
1304
|
+
return res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
const id = body.id;
|
|
1308
|
+
const rpcMethod = body.method;
|
|
1309
|
+
const logMsg = `[${new Date().toISOString()}] Streamable HTTP: ${rpcMethod} id=${id}\n`;
|
|
1310
|
+
try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
|
|
1311
|
+
|
|
1312
|
+
// Notifications — no response needed
|
|
1313
|
+
if (rpcMethod === "notifications/initialized" || rpcMethod === "initialized") {
|
|
1314
|
+
res.writeHead(204, CORS);
|
|
1315
|
+
return res.end();
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
if (rpcMethod === "initialize") {
|
|
1319
|
+
const result = {
|
|
1320
|
+
protocolVersion: "2025-03-26",
|
|
1321
|
+
serverInfo: { name: "clauth", version: VERSION },
|
|
1322
|
+
capabilities: { tools: {} }
|
|
1323
|
+
};
|
|
1324
|
+
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
1325
|
+
return res.end(JSON.stringify({ jsonrpc: "2.0", id, result }));
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
if (rpcMethod === "tools/list") {
|
|
1329
|
+
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
1330
|
+
return res.end(JSON.stringify({ jsonrpc: "2.0", id, result: { tools: MCP_TOOLS } }));
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
if (rpcMethod === "tools/call") {
|
|
1334
|
+
const { name, arguments: args } = body.params || {};
|
|
1335
|
+
const vault = sseVault();
|
|
1336
|
+
try {
|
|
1337
|
+
const result = await handleMcpTool(vault, name, args || {});
|
|
1338
|
+
password = vault.password;
|
|
1339
|
+
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
1340
|
+
return res.end(JSON.stringify({ jsonrpc: "2.0", id, result }));
|
|
1341
|
+
} catch (err) {
|
|
1342
|
+
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
1343
|
+
return res.end(JSON.stringify({ jsonrpc: "2.0", id, result: mcpError(`Internal error: ${err.message}`) }));
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// Unknown method
|
|
1348
|
+
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
1349
|
+
return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32601, message: `Unknown method: ${rpcMethod}` } }));
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// ── MCP SSE transport (legacy) ───────────────────────
|
|
1027
1353
|
// GET /sse — open SSE stream, receive endpoint event
|
|
1028
1354
|
if (method === "GET" && reqPath === "/sse") {
|
|
1029
1355
|
const sessionId = `ses_${++sseCounter}_${Date.now()}`;
|
|
@@ -1038,7 +1364,9 @@ function createServer(initPassword, whitelist, port) {
|
|
|
1038
1364
|
sseSessions.set(sessionId, { res, initialized: false });
|
|
1039
1365
|
|
|
1040
1366
|
// Send the endpoint URI the client should POST to
|
|
1041
|
-
|
|
1367
|
+
// Use absolute URL when tunnel is active so remote clients can resolve it
|
|
1368
|
+
const basePath = tunnelUrl || `http://127.0.0.1:${port}`;
|
|
1369
|
+
const endpoint = `${basePath}/message?sessionId=${sessionId}`;
|
|
1042
1370
|
res.write(`event: endpoint\ndata: ${endpoint}\n\n`);
|
|
1043
1371
|
|
|
1044
1372
|
// Keepalive every 15s
|
|
@@ -1162,6 +1490,15 @@ function createServer(initPassword, whitelist, port) {
|
|
|
1162
1490
|
});
|
|
1163
1491
|
}
|
|
1164
1492
|
|
|
1493
|
+
// GET /mcp-setup — OAuth credentials for claude.ai MCP setup (localhost only)
|
|
1494
|
+
if (method === "GET" && reqPath === "/mcp-setup") {
|
|
1495
|
+
return ok(res, {
|
|
1496
|
+
url: tunnelUrl && tunnelUrl.startsWith("http") ? `${tunnelUrl}/mcp` : null,
|
|
1497
|
+
clientId: OAUTH_CLIENT_ID,
|
|
1498
|
+
clientSecret: OAUTH_CLIENT_SECRET,
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1165
1502
|
// POST /tunnel — start or stop tunnel manually
|
|
1166
1503
|
if (method === "POST" && reqPath === "/tunnel") {
|
|
1167
1504
|
if (lockedGuard(res)) return;
|
|
@@ -1446,10 +1783,21 @@ function createServer(initPassword, whitelist, port) {
|
|
|
1446
1783
|
}
|
|
1447
1784
|
}
|
|
1448
1785
|
|
|
1449
|
-
// Unknown route
|
|
1786
|
+
// Unknown route — don't count browser/MCP noise as auth failures
|
|
1787
|
+
// Don't count browser noise, MCP discovery probes, or OAuth probes as auth failures
|
|
1788
|
+
const isBenign = reqPath.startsWith("/.well-known/") || [
|
|
1789
|
+
"/favicon.ico", "/robots.txt", "/apple-touch-icon.png", "/apple-touch-icon-precomposed.png",
|
|
1790
|
+
"/sse", "/mcp", "/message", "/register", "/authorize", "/token",
|
|
1791
|
+
].includes(reqPath);
|
|
1792
|
+
if (isBenign) {
|
|
1793
|
+
res.writeHead(404, { "Content-Type": "application/json", ...CORS });
|
|
1794
|
+
return res.end(JSON.stringify({ error: "Not found" }));
|
|
1795
|
+
}
|
|
1450
1796
|
return strike(res, 404, `Unknown endpoint: ${reqPath}`);
|
|
1451
1797
|
});
|
|
1452
1798
|
|
|
1799
|
+
server.__oauthClientId = OAUTH_CLIENT_ID;
|
|
1800
|
+
server.__oauthClientSecret = OAUTH_CLIENT_SECRET;
|
|
1453
1801
|
return server;
|
|
1454
1802
|
}
|
|
1455
1803
|
|
|
@@ -1465,6 +1813,7 @@ async function verifyAuth(password) {
|
|
|
1465
1813
|
async function actionStart(opts) {
|
|
1466
1814
|
const port = parseInt(opts.port || "52437", 10);
|
|
1467
1815
|
const password = opts.pw;
|
|
1816
|
+
const tunnelHostname = opts.tunnel || null;
|
|
1468
1817
|
const whitelist = opts.services
|
|
1469
1818
|
? opts.services.split(",").map(s => s.trim().toLowerCase())
|
|
1470
1819
|
: null;
|
|
@@ -1490,7 +1839,7 @@ async function actionStart(opts) {
|
|
|
1490
1839
|
}
|
|
1491
1840
|
}
|
|
1492
1841
|
|
|
1493
|
-
const server = createServer(password, whitelist, port);
|
|
1842
|
+
const server = createServer(password, whitelist, port, tunnelHostname);
|
|
1494
1843
|
server.listen(port, "127.0.0.1", () => {
|
|
1495
1844
|
writePid(process.pid, port);
|
|
1496
1845
|
const msg = `[${new Date().toISOString()}] clauth serve started — PID ${process.pid}, port ${port}, services: ${whitelist ? whitelist.join(",") : "all"}\n`;
|
|
@@ -1534,6 +1883,7 @@ async function actionStart(opts) {
|
|
|
1534
1883
|
const childArgs = [cliEntry, "serve", "start", "--port", String(port)];
|
|
1535
1884
|
if (password) childArgs.push("--pw", password);
|
|
1536
1885
|
if (opts.services) childArgs.push("--services", opts.services);
|
|
1886
|
+
if (tunnelHostname) childArgs.push("--tunnel", tunnelHostname);
|
|
1537
1887
|
|
|
1538
1888
|
const out = fs.openSync(LOG_FILE, "a");
|
|
1539
1889
|
const child = spawn(process.execPath, childArgs, {
|
|
@@ -1650,6 +2000,7 @@ async function actionRestart(opts) {
|
|
|
1650
2000
|
async function actionForeground(opts) {
|
|
1651
2001
|
const port = parseInt(opts.port || "52437", 10);
|
|
1652
2002
|
const password = opts.pw || null;
|
|
2003
|
+
const tunnelHostname = opts.tunnel || null;
|
|
1653
2004
|
const whitelist = opts.services
|
|
1654
2005
|
? opts.services.split(",").map(s => s.trim().toLowerCase())
|
|
1655
2006
|
: null;
|
|
@@ -1671,10 +2022,19 @@ async function actionForeground(opts) {
|
|
|
1671
2022
|
console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
|
|
1672
2023
|
console.log(chalk.gray(` Lockout: 3 failures → exit\n`));
|
|
1673
2024
|
|
|
1674
|
-
const server = createServer(password, whitelist, port);
|
|
2025
|
+
const server = createServer(password, whitelist, port, tunnelHostname);
|
|
1675
2026
|
server.listen(port, "127.0.0.1", () => {
|
|
1676
2027
|
writePid(process.pid, port);
|
|
1677
2028
|
console.log(chalk.green(` clauth serve → http://127.0.0.1:${port}`));
|
|
2029
|
+
if (tunnelHostname) {
|
|
2030
|
+
console.log(chalk.cyan(` Tunnel: https://${tunnelHostname}/sse`));
|
|
2031
|
+
console.log("");
|
|
2032
|
+
console.log(chalk.yellow(" ── claude.ai Custom Connector ──"));
|
|
2033
|
+
console.log(chalk.white(` URL: https://${tunnelHostname}/mcp`));
|
|
2034
|
+
console.log(chalk.white(` Client ID: ${server.__oauthClientId}`));
|
|
2035
|
+
console.log(chalk.white(` Client Secret: ${server.__oauthClientSecret}`));
|
|
2036
|
+
console.log(chalk.gray(" (paste these into Advanced Settings when adding the connector)"));
|
|
2037
|
+
}
|
|
1678
2038
|
if (!password) console.log(chalk.cyan(` 👉 Open http://127.0.0.1:${port} to unlock`));
|
|
1679
2039
|
console.log(chalk.gray(" Ctrl+C to stop\n"));
|
|
1680
2040
|
// Auto-open browser
|
|
@@ -2048,7 +2408,7 @@ function createMcpServer(initPassword, whitelist) {
|
|
|
2048
2408
|
get machineHash() { return ensureMachineHash(); },
|
|
2049
2409
|
whitelist,
|
|
2050
2410
|
failCount: 0,
|
|
2051
|
-
MAX_FAILS:
|
|
2411
|
+
MAX_FAILS: 10,
|
|
2052
2412
|
};
|
|
2053
2413
|
|
|
2054
2414
|
const rl = createInterface({ input: process.stdin, terminal: false });
|
package/cli/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import * as api from "./api.js";
|
|
|
12
12
|
import os from "os";
|
|
13
13
|
|
|
14
14
|
const config = new Conf(getConfOptions());
|
|
15
|
-
const VERSION = "0.
|
|
15
|
+
const VERSION = "0.7.0";
|
|
16
16
|
|
|
17
17
|
// ============================================================
|
|
18
18
|
// Password prompt helper
|
|
@@ -468,6 +468,7 @@ program
|
|
|
468
468
|
.option("--port <n>", "Port (default: 52437)")
|
|
469
469
|
.option("-p, --pw <password>", "clauth password (optional — omit to start locked, unlock in browser)")
|
|
470
470
|
.option("--services <list>", "Comma-separated service whitelist (default: all)")
|
|
471
|
+
.option("--tunnel <hostname>", "Fixed tunnel hostname (e.g. clauth.prtrust.fund) — uses named Cloudflare Tunnel instead of random URL")
|
|
471
472
|
.option("--action <action>", "Internal: action override for daemon child")
|
|
472
473
|
.addHelpText("after", `
|
|
473
474
|
Actions:
|