@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.
@@ -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 · 3-strike lockout</div>
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 = 3;
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
- const proc = spawnProc(cfBin, ["tunnel", "run", tunnelName], {
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 logs to stderr — parse for connection info
1031
+ // cloudflared prints output to stderr
925
1032
  let stderrBuf = "";
926
1033
  proc.stderr.on("data", (chunk) => {
927
- const text = chunk.toString();
928
- stderrBuf += text;
929
-
930
- // Detect connection registered (named tunnel is live)
931
- if (!tunnelUrl && stderrBuf.includes("Registered tunnel connection")) {
932
- // Named tunnel URL comes from config, not stderr.
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
- const url = new URL(req.url, `http://127.0.0.1:${port}`);
1045
- const reqPath = url.pathname;
1046
- const method = req.method;
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
- // ── MCP SSE transport ─────────────────────────────────
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
- const endpoint = `/message?sessionId=${sessionId}`;
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: 3,
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.5.1";
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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/clauth",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
5
5
  "type": "module",
6
6
  "bin": {