@lifeaitools/clauth 0.5.1 → 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 +382 -44
- 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,15 +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
|
-
// Named tunnel "clauth" — ingress configured in ~/.cloudflared/config.yml
|
|
901
|
-
const tunnelName = "clauth";
|
|
902
|
-
|
|
903
998
|
// Resolve cloudflared binary — may not be on PATH in bash shells
|
|
904
999
|
let cfBin = "cloudflared";
|
|
905
1000
|
if (os.platform() === "win32") {
|
|
@@ -915,40 +1010,33 @@ function createServer(initPassword, whitelist, port) {
|
|
|
915
1010
|
}
|
|
916
1011
|
}
|
|
917
1012
|
|
|
918
|
-
|
|
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, {
|
|
919
1026
|
stdio: ["ignore", "pipe", "pipe"],
|
|
920
1027
|
});
|
|
921
1028
|
|
|
922
1029
|
tunnelProc = proc;
|
|
923
1030
|
|
|
924
|
-
// cloudflared
|
|
1031
|
+
// cloudflared prints output to stderr
|
|
925
1032
|
let stderrBuf = "";
|
|
926
1033
|
proc.stderr.on("data", (chunk) => {
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
// Read it from cloudflared config if available.
|
|
934
|
-
try {
|
|
935
|
-
const cfgPath = path.join(os.homedir(), ".cloudflared", "config.yml");
|
|
936
|
-
const cfgText = fs.readFileSync(cfgPath, "utf8");
|
|
937
|
-
const hostnameMatch = cfgText.match(/hostname:\s*(\S+)/);
|
|
938
|
-
if (hostnameMatch) {
|
|
939
|
-
tunnelUrl = hostnameMatch[1].startsWith("http") ? hostnameMatch[1] : `https://${hostnameMatch[1]}`;
|
|
940
|
-
}
|
|
941
|
-
} catch {}
|
|
942
|
-
if (!tunnelUrl) tunnelUrl = `(named tunnel "${tunnelName}" connected — check DNS)`;
|
|
943
|
-
const logLine = `[${new Date().toISOString()}] Tunnel started: ${tunnelUrl}\n`;
|
|
944
|
-
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
// Quick tunnel fallback: trycloudflare.com URL
|
|
948
|
-
if (!tunnelUrl) {
|
|
949
|
-
const quickMatch = stderrBuf.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
950
|
-
if (quickMatch) {
|
|
951
|
-
tunnelUrl = quickMatch[0];
|
|
1034
|
+
stderrBuf += chunk.toString();
|
|
1035
|
+
// For quick tunnels, capture the random URL from stderr
|
|
1036
|
+
if (!tunnelHostname && !tunnelUrl) {
|
|
1037
|
+
const match = stderrBuf.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
1038
|
+
if (match) {
|
|
1039
|
+
tunnelUrl = match[0];
|
|
952
1040
|
const logLine = `[${new Date().toISOString()}] Tunnel started: ${tunnelUrl}\n`;
|
|
953
1041
|
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
954
1042
|
}
|
|
@@ -976,6 +1064,11 @@ function createServer(initPassword, whitelist, port) {
|
|
|
976
1064
|
// Give it a moment to start
|
|
977
1065
|
await new Promise(r => setTimeout(r, 4000));
|
|
978
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
|
+
|
|
979
1072
|
} catch (err) {
|
|
980
1073
|
tunnelError = err.message;
|
|
981
1074
|
tunnelProc = null;
|
|
@@ -1028,9 +1121,18 @@ function createServer(initPassword, whitelist, port) {
|
|
|
1028
1121
|
}
|
|
1029
1122
|
|
|
1030
1123
|
const server = http.createServer(async (req, res) => {
|
|
1031
|
-
// Hard reject anything not from loopback
|
|
1032
1124
|
const remote = req.socket.remoteAddress;
|
|
1033
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
|
|
1034
1136
|
if (!isLocal) {
|
|
1035
1137
|
return strike(res, 403, `Rejected non-local address: ${remote}`);
|
|
1036
1138
|
}
|
|
@@ -1041,11 +1143,213 @@ function createServer(initPassword, whitelist, port) {
|
|
|
1041
1143
|
return res.end();
|
|
1042
1144
|
}
|
|
1043
1145
|
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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);
|
|
1047
1222
|
|
|
1048
|
-
|
|
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
|
+
}
|
|
1228
|
+
|
|
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) ───────────────────────
|
|
1049
1353
|
// GET /sse — open SSE stream, receive endpoint event
|
|
1050
1354
|
if (method === "GET" && reqPath === "/sse") {
|
|
1051
1355
|
const sessionId = `ses_${++sseCounter}_${Date.now()}`;
|
|
@@ -1060,7 +1364,9 @@ function createServer(initPassword, whitelist, port) {
|
|
|
1060
1364
|
sseSessions.set(sessionId, { res, initialized: false });
|
|
1061
1365
|
|
|
1062
1366
|
// Send the endpoint URI the client should POST to
|
|
1063
|
-
|
|
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}`;
|
|
1064
1370
|
res.write(`event: endpoint\ndata: ${endpoint}\n\n`);
|
|
1065
1371
|
|
|
1066
1372
|
// Keepalive every 15s
|
|
@@ -1184,6 +1490,15 @@ function createServer(initPassword, whitelist, port) {
|
|
|
1184
1490
|
});
|
|
1185
1491
|
}
|
|
1186
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
|
+
|
|
1187
1502
|
// POST /tunnel — start or stop tunnel manually
|
|
1188
1503
|
if (method === "POST" && reqPath === "/tunnel") {
|
|
1189
1504
|
if (lockedGuard(res)) return;
|
|
@@ -1468,10 +1783,21 @@ function createServer(initPassword, whitelist, port) {
|
|
|
1468
1783
|
}
|
|
1469
1784
|
}
|
|
1470
1785
|
|
|
1471
|
-
// 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
|
+
}
|
|
1472
1796
|
return strike(res, 404, `Unknown endpoint: ${reqPath}`);
|
|
1473
1797
|
});
|
|
1474
1798
|
|
|
1799
|
+
server.__oauthClientId = OAUTH_CLIENT_ID;
|
|
1800
|
+
server.__oauthClientSecret = OAUTH_CLIENT_SECRET;
|
|
1475
1801
|
return server;
|
|
1476
1802
|
}
|
|
1477
1803
|
|
|
@@ -1487,6 +1813,7 @@ async function verifyAuth(password) {
|
|
|
1487
1813
|
async function actionStart(opts) {
|
|
1488
1814
|
const port = parseInt(opts.port || "52437", 10);
|
|
1489
1815
|
const password = opts.pw;
|
|
1816
|
+
const tunnelHostname = opts.tunnel || null;
|
|
1490
1817
|
const whitelist = opts.services
|
|
1491
1818
|
? opts.services.split(",").map(s => s.trim().toLowerCase())
|
|
1492
1819
|
: null;
|
|
@@ -1512,7 +1839,7 @@ async function actionStart(opts) {
|
|
|
1512
1839
|
}
|
|
1513
1840
|
}
|
|
1514
1841
|
|
|
1515
|
-
const server = createServer(password, whitelist, port);
|
|
1842
|
+
const server = createServer(password, whitelist, port, tunnelHostname);
|
|
1516
1843
|
server.listen(port, "127.0.0.1", () => {
|
|
1517
1844
|
writePid(process.pid, port);
|
|
1518
1845
|
const msg = `[${new Date().toISOString()}] clauth serve started — PID ${process.pid}, port ${port}, services: ${whitelist ? whitelist.join(",") : "all"}\n`;
|
|
@@ -1556,6 +1883,7 @@ async function actionStart(opts) {
|
|
|
1556
1883
|
const childArgs = [cliEntry, "serve", "start", "--port", String(port)];
|
|
1557
1884
|
if (password) childArgs.push("--pw", password);
|
|
1558
1885
|
if (opts.services) childArgs.push("--services", opts.services);
|
|
1886
|
+
if (tunnelHostname) childArgs.push("--tunnel", tunnelHostname);
|
|
1559
1887
|
|
|
1560
1888
|
const out = fs.openSync(LOG_FILE, "a");
|
|
1561
1889
|
const child = spawn(process.execPath, childArgs, {
|
|
@@ -1672,6 +2000,7 @@ async function actionRestart(opts) {
|
|
|
1672
2000
|
async function actionForeground(opts) {
|
|
1673
2001
|
const port = parseInt(opts.port || "52437", 10);
|
|
1674
2002
|
const password = opts.pw || null;
|
|
2003
|
+
const tunnelHostname = opts.tunnel || null;
|
|
1675
2004
|
const whitelist = opts.services
|
|
1676
2005
|
? opts.services.split(",").map(s => s.trim().toLowerCase())
|
|
1677
2006
|
: null;
|
|
@@ -1693,10 +2022,19 @@ async function actionForeground(opts) {
|
|
|
1693
2022
|
console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
|
|
1694
2023
|
console.log(chalk.gray(` Lockout: 3 failures → exit\n`));
|
|
1695
2024
|
|
|
1696
|
-
const server = createServer(password, whitelist, port);
|
|
2025
|
+
const server = createServer(password, whitelist, port, tunnelHostname);
|
|
1697
2026
|
server.listen(port, "127.0.0.1", () => {
|
|
1698
2027
|
writePid(process.pid, port);
|
|
1699
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
|
+
}
|
|
1700
2038
|
if (!password) console.log(chalk.cyan(` 👉 Open http://127.0.0.1:${port} to unlock`));
|
|
1701
2039
|
console.log(chalk.gray(" Ctrl+C to stop\n"));
|
|
1702
2040
|
// Auto-open browser
|
|
@@ -2070,7 +2408,7 @@ function createMcpServer(initPassword, whitelist) {
|
|
|
2070
2408
|
get machineHash() { return ensureMachineHash(); },
|
|
2071
2409
|
whitelist,
|
|
2072
2410
|
failCount: 0,
|
|
2073
|
-
MAX_FAILS:
|
|
2411
|
+
MAX_FAILS: 10,
|
|
2074
2412
|
};
|
|
2075
2413
|
|
|
2076
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:
|