@lifeaitools/clauth 1.5.34 → 1.5.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/cli/commands/serve.js +212 -89
  2. package/package.json +1 -1
@@ -1969,12 +1969,11 @@ function copyMcp(elId) {
1969
1969
  }
1970
1970
 
1971
1971
  async function rollMcpCreds() {
1972
- if (!confirm("Roll MCP credentials? You will need to re-add connectors in claude.ai with the new credentials.")) return;
1972
+ if (!confirm("Invalidate all OAuth tokens? Claude.ai connectors will need to re-authenticate.")) return;
1973
1973
  try {
1974
1974
  const resp = await fetch(BASE + "/roll-mcp-creds", { method: "POST" }).then(r => r.json());
1975
- if (resp.client_id) {
1976
- document.getElementById("mcp-client-id").textContent = resp.client_id;
1977
- document.getElementById("mcp-client-secret").textContent = resp.client_secret;
1975
+ if (resp.tokens_invalidated) {
1976
+ alert("All OAuth clients and tokens invalidated. Claude.ai will re-register automatically on next connection.");
1978
1977
  }
1979
1978
  } catch(e) { alert("Failed: " + e.message); }
1980
1979
  }
@@ -2374,12 +2373,11 @@ async function wizShowMcpSetup(hostname) {
2374
2373
  }
2375
2374
 
2376
2375
  async function wizRollCreds() {
2377
- if (!confirm("Roll MCP credentials? Existing claude.ai connectors will need to be re-added with the new credentials.")) return;
2376
+ if (!confirm("Invalidate all OAuth tokens? Claude.ai connectors will need to re-authenticate.")) return;
2378
2377
  try {
2379
2378
  const resp = await apiFetch("/roll-mcp-creds", { method: "POST" });
2380
- if (resp?.client_id) {
2381
- document.getElementById("wiz-mcp-cid").textContent = resp.client_id;
2382
- document.getElementById("wiz-mcp-sec").textContent = resp.client_secret;
2379
+ if (resp?.tokens_invalidated) {
2380
+ alert("All OAuth clients and tokens invalidated. Claude.ai will re-register automatically on next connection.");
2383
2381
  }
2384
2382
  } catch(e) { alert("Failed to roll credentials: " + e.message); }
2385
2383
  }
@@ -2545,12 +2543,11 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
2545
2543
  let tunnelStatus = "not_started"; // "not_started" | "not_configured" | "starting" | "live" | "error" | "missing_cloudflared"
2546
2544
 
2547
2545
  // ── OAuth provider (self-contained for claude.ai MCP) ──────
2548
- const oauthClients = new Map(); // client_id → { client_secret, redirect_uris, client_name }
2546
+ const oauthClients = new Map(); // client_id → { redirect_uris, client_name, token_endpoint_auth_method }
2549
2547
  const oauthCodes = new Map(); // code → { client_id, redirect_uri, code_challenge, expires }
2550
2548
 
2551
2549
  // Persist tokens + credentials to disk so daemon restarts don't invalidate sessions
2552
2550
  const TOKENS_FILE = path.join(os.tmpdir(), "clauth-oauth-tokens.json");
2553
- const CREDS_FILE = path.join(os.tmpdir(), "clauth-oauth-creds.json");
2554
2551
  function loadTokens() {
2555
2552
  try { return new Set(JSON.parse(fs.readFileSync(TOKENS_FILE, "utf8"))); } catch { return new Set(); }
2556
2553
  }
@@ -2559,29 +2556,6 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
2559
2556
  }
2560
2557
  const oauthTokens = loadTokens(); // active access tokens — persisted across restarts
2561
2558
 
2562
- // Stable client credentials — persist across restarts so claude.ai custom setup works
2563
- function loadOrCreateCreds() {
2564
- try {
2565
- const saved = JSON.parse(fs.readFileSync(CREDS_FILE, "utf8"));
2566
- if (saved.client_id && saved.client_secret) return saved;
2567
- } catch {}
2568
- const creds = {
2569
- client_id: crypto.randomBytes(16).toString("hex"),
2570
- client_secret: crypto.randomBytes(32).toString("hex"),
2571
- };
2572
- try { fs.writeFileSync(CREDS_FILE, JSON.stringify(creds)); } catch {}
2573
- return creds;
2574
- }
2575
- const stableCreds = loadOrCreateCreds();
2576
- let OAUTH_CLIENT_ID = stableCreds.client_id;
2577
- let OAUTH_CLIENT_SECRET = stableCreds.client_secret;
2578
- oauthClients.set(OAUTH_CLIENT_ID, {
2579
- client_id: OAUTH_CLIENT_ID, client_secret: OAUTH_CLIENT_SECRET,
2580
- client_name: "claude.ai", redirect_uris: ["https://claude.ai/api/mcp/auth_callback"],
2581
- grant_types: ["authorization_code"], response_types: ["code"],
2582
- token_endpoint_auth_method: "client_secret_post",
2583
- });
2584
-
2585
2559
  function oauthBase() { return tunnelUrl || `http://127.0.0.1:${port}`; }
2586
2560
  function sha256base64url(str) { return crypto.createHash("sha256").update(str).digest("base64url"); }
2587
2561
 
@@ -2934,21 +2908,183 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
2934
2908
  return res.end();
2935
2909
  }
2936
2910
 
2937
- // ── OAuth Discovery 404 well-known only ──────────────
2938
- // claude.ai ignores metadata endpoints and constructs /register, /authorize,
2939
- // /token from the domain root (issue #82). Keep well-known as 404 so claude.ai
2940
- // uses the fallback paths. OAuth endpoints below are live.
2941
- // well-known OAuth discovery — HTML 404, no CORS, no JSON (match Express/regen-media)
2942
- if (reqPath.startsWith("/.well-known/oauth-protected-resource") ||
2943
- reqPath === "/.well-known/oauth-authorization-server") {
2944
- res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
2945
- return res.end("<!DOCTYPE html><html><head><title>404</title></head><body><h1>Not Found</h1></body></html>");
2911
+ // ── OAuth Discovery (RFC 9728 + RFC 8414) ──────────────
2912
+ // Restore full well-known + OAuth so custom setup with client_id/secret works.
2913
+ if (reqPath.startsWith("/.well-known/oauth-protected-resource")) {
2914
+ const base = oauthBase();
2915
+ const suffix = reqPath.replace("/.well-known/oauth-protected-resource", "").replace(/^\//, "");
2916
+ const resourcePath = suffix && ["/gws", "/clauth", "/mcp", "/sse"].includes("/" + suffix) ? "/" + suffix : "/sse";
2917
+ res.writeHead(200, { "Content-Type": "application/json", ...CORS });
2918
+ return res.end(JSON.stringify({
2919
+ resource: `${base}${resourcePath}`,
2920
+ authorization_servers: [base],
2921
+ scopes_supported: ["mcp:tools"],
2922
+ bearer_methods_supported: ["header"],
2923
+ }));
2924
+ }
2925
+
2926
+ if (reqPath === "/.well-known/oauth-authorization-server") {
2927
+ const base = oauthBase();
2928
+ res.writeHead(200, { "Content-Type": "application/json", ...CORS });
2929
+ return res.end(JSON.stringify({
2930
+ issuer: base,
2931
+ authorization_endpoint: `${base}/authorize`,
2932
+ token_endpoint: `${base}/token`,
2933
+ registration_endpoint: `${base}/register`,
2934
+ response_types_supported: ["code"],
2935
+ grant_types_supported: ["authorization_code"],
2936
+ token_endpoint_auth_methods_supported: ["none"],
2937
+ code_challenge_methods_supported: ["S256"],
2938
+ scopes_supported: ["mcp:tools"],
2939
+ }));
2940
+ }
2941
+
2942
+ // ── Dynamic Client Registration (RFC 7591) ──────────────
2943
+ if (method === "POST" && reqPath === "/register") {
2944
+ let body;
2945
+ try { body = await readBody(req); } catch {
2946
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
2947
+ return res.end(JSON.stringify({ error: "invalid_request" }));
2948
+ }
2949
+ const clientId = crypto.randomBytes(16).toString("hex");
2950
+ const client = {
2951
+ client_id: clientId,
2952
+ // NO client_secret — public client (OAuth 2.1)
2953
+ client_name: body.client_name || "unknown",
2954
+ redirect_uris: body.redirect_uris || [],
2955
+ grant_types: body.grant_types || ["authorization_code"],
2956
+ response_types: body.response_types || ["code"],
2957
+ token_endpoint_auth_method: "none", // PUBLIC CLIENT
2958
+ };
2959
+ oauthClients.set(clientId, client);
2960
+ const logMsg = `[${new Date().toISOString()}] OAuth: registered public client ${clientId} (${client.client_name})\n`;
2961
+ try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
2962
+ res.writeHead(201, { "Content-Type": "application/json", ...CORS });
2963
+ return res.end(JSON.stringify(client));
2964
+ }
2965
+
2966
+ // ── Authorization endpoint — auto-approve (PKCE mandatory) ──
2967
+ if (method === "GET" && reqPath === "/authorize") {
2968
+ const clientId = url.searchParams.get("client_id");
2969
+ const redirectUri = url.searchParams.get("redirect_uri");
2970
+ const state = url.searchParams.get("state");
2971
+ const codeChallenge = url.searchParams.get("code_challenge");
2972
+ const codeChallengeMethod = url.searchParams.get("code_challenge_method");
2973
+
2974
+ // Validate required params
2975
+ if (!clientId || !redirectUri) {
2976
+ res.writeHead(400, { "Content-Type": "text/plain", ...CORS });
2977
+ return res.end("Missing client_id or redirect_uri");
2978
+ }
2979
+
2980
+ // PKCE is MANDATORY — reject if missing
2981
+ if (!codeChallenge || codeChallengeMethod !== "S256") {
2982
+ res.writeHead(400, { "Content-Type": "text/plain", ...CORS });
2983
+ return res.end("PKCE required: code_challenge with S256 method");
2984
+ }
2985
+
2986
+ // Validate client exists
2987
+ if (!oauthClients.has(clientId)) {
2988
+ res.writeHead(400, { "Content-Type": "text/plain", ...CORS });
2989
+ return res.end("Unknown client_id");
2990
+ }
2991
+
2992
+ // Validate redirect_uri matches registered URIs
2993
+ const client = oauthClients.get(clientId);
2994
+ if (client.redirect_uris.length > 0 && !client.redirect_uris.includes(redirectUri)) {
2995
+ res.writeHead(400, { "Content-Type": "text/plain", ...CORS });
2996
+ return res.end("redirect_uri mismatch");
2997
+ }
2998
+
2999
+ // Auto-approve (no user interaction — clauth is a personal vault)
3000
+ const code = crypto.randomBytes(32).toString("hex");
3001
+ oauthCodes.set(code, {
3002
+ client_id: clientId,
3003
+ redirect_uri: redirectUri,
3004
+ code_challenge: codeChallenge,
3005
+ expires: Date.now() + 300_000, // 5 minutes
3006
+ });
3007
+
3008
+ const redirect = new URL(redirectUri);
3009
+ redirect.searchParams.set("code", code);
3010
+ if (state) redirect.searchParams.set("state", state);
3011
+
3012
+ const logMsg = `[${new Date().toISOString()}] OAuth: authorize → code for ${clientId}, redirect to ${redirect.origin}\n`;
3013
+ try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
3014
+ res.writeHead(302, { Location: redirect.toString(), ...CORS });
3015
+ return res.end();
2946
3016
  }
2947
3017
 
2948
- // ── OAuth endpoints REMOVED ──────────────
2949
- // claude.ai's OAuth is bugged (token issued, never used — anthropics/claude-code#46140).
2950
- // regen-media works because it has NO OAuth handlers at all.
2951
- // These paths fall through to the catch-all 404 at the bottom.
3018
+ // ── Token endpoint (public client + PKCE mandatory) ─────
3019
+ if (method === "POST" && reqPath === "/token") {
3020
+ let body;
3021
+ const ct = req.headers["content-type"] || "";
3022
+ try {
3023
+ if (ct.includes("application/json")) {
3024
+ body = await readBody(req);
3025
+ } else {
3026
+ // application/x-www-form-urlencoded (Claude uses this)
3027
+ const raw = await readRawBody(req);
3028
+ body = Object.fromEntries(new URLSearchParams(raw));
3029
+ }
3030
+ } catch {
3031
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
3032
+ return res.end(JSON.stringify({ error: "invalid_request" }));
3033
+ }
3034
+
3035
+ // Validate grant_type
3036
+ if (body.grant_type !== "authorization_code") {
3037
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
3038
+ return res.end(JSON.stringify({ error: "unsupported_grant_type" }));
3039
+ }
3040
+
3041
+ // Validate authorization code exists and is not expired
3042
+ const stored = oauthCodes.get(body.code);
3043
+ if (!stored || stored.expires < Date.now()) {
3044
+ oauthCodes.delete(body.code);
3045
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
3046
+ return res.end(JSON.stringify({ error: "invalid_grant", error_description: "Code expired or invalid" }));
3047
+ }
3048
+
3049
+ // Validate client_id matches
3050
+ if (body.client_id !== stored.client_id) {
3051
+ oauthCodes.delete(body.code);
3052
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
3053
+ return res.end(JSON.stringify({ error: "invalid_grant", error_description: "client_id mismatch" }));
3054
+ }
3055
+
3056
+ // Validate redirect_uri matches
3057
+ if (body.redirect_uri !== stored.redirect_uri) {
3058
+ oauthCodes.delete(body.code);
3059
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
3060
+ return res.end(JSON.stringify({ error: "invalid_grant", error_description: "redirect_uri mismatch" }));
3061
+ }
3062
+
3063
+ // PKCE verification — MANDATORY (not optional)
3064
+ if (!body.code_verifier) {
3065
+ oauthCodes.delete(body.code);
3066
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
3067
+ return res.end(JSON.stringify({ error: "invalid_grant", error_description: "code_verifier required" }));
3068
+ }
3069
+
3070
+ const computed = sha256base64url(body.code_verifier);
3071
+ if (computed !== stored.code_challenge) {
3072
+ oauthCodes.delete(body.code);
3073
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
3074
+ return res.end(JSON.stringify({ error: "invalid_grant", error_description: "PKCE verification failed" }));
3075
+ }
3076
+
3077
+ // All checks passed — delete code (one-time use) and issue token
3078
+ oauthCodes.delete(body.code);
3079
+ const accessToken = crypto.randomBytes(32).toString("hex");
3080
+ oauthTokens.add(accessToken);
3081
+ saveTokens(oauthTokens);
3082
+
3083
+ const logMsg = `[${new Date().toISOString()}] OAuth: token issued for ${stored.client_id} (token=${accessToken.slice(0,8)}…)\n`;
3084
+ try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
3085
+ res.writeHead(200, { "Content-Type": "application/json", ...CORS });
3086
+ return res.end(JSON.stringify({ access_token: accessToken, token_type: "Bearer", scope: "mcp:tools", expires_in: 86400 }));
3087
+ }
2952
3088
 
2953
3089
  // ── MCP path helpers ──
2954
3090
  const MCP_PATHS = ["/mcp", "/gws", "/clauth"];
@@ -2964,18 +3100,29 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
2964
3100
  return "clauth";
2965
3101
  }
2966
3102
 
2967
- // ── MCP endpoint auth ──
2968
- // No 401 gate — tunnel URL is the shared secret. Accept all connections.
2969
- // If Bearer token present, mark as remote for vault scoping.
3103
+ // ── MCP endpoint auth — 401 gate (OAuth 2.1 protocol) ──
2970
3104
  if (method === "POST" && (reqPath === "/sse" || isMcpPath)) {
2971
3105
  const authHeader = req.headers.authorization;
2972
- if (authHeader?.startsWith("Bearer ")) {
2973
- const token = authHeader.slice(7);
2974
- if (oauthTokens.has(token) || token === OAUTH_CLIENT_SECRET) {
2975
- req._clauthRemote = true;
2976
- }
3106
+ const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
3107
+
3108
+ if (!token || !oauthTokens.has(token)) {
3109
+ // No valid Bearer token → return 401 with discovery hint
3110
+ const base = oauthBase();
3111
+ const resourcePath = isMcpPath ? reqPath.slice(1) : "sse"; // "mcp", "gws", "clauth", or "sse"
3112
+ const resourceMeta = `${base}/.well-known/oauth-protected-resource/${resourcePath}`;
3113
+ res.writeHead(401, {
3114
+ "Content-Type": "application/json",
3115
+ "WWW-Authenticate": `Bearer resource_metadata="${resourceMeta}"`,
3116
+ ...CORS,
3117
+ });
3118
+ return res.end(JSON.stringify({
3119
+ error: "unauthorized",
3120
+ error_description: "Bearer token required",
3121
+ }));
2977
3122
  }
2978
- // fall through to MCP handling
3123
+
3124
+ // Valid token — mark as remote and fall through to MCP handling
3125
+ req._clauthRemote = true;
2979
3126
  }
2980
3127
 
2981
3128
  // ── MCP Streamable HTTP transport ──
@@ -3215,42 +3362,26 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
3215
3362
  });
3216
3363
  }
3217
3364
 
3218
- // GET /mcp-setup — OAuth credentials for claude.ai MCP setup (localhost only)
3365
+ // GET /mcp-setup — OAuth setup info for claude.ai MCP (localhost only)
3219
3366
  if (method === "GET" && reqPath === "/mcp-setup") {
3220
3367
  const base = tunnelUrl && tunnelUrl.startsWith("http") ? tunnelUrl : null;
3221
3368
  return ok(res, {
3222
3369
  url: base ? `${base}/clauth` : null,
3223
3370
  gwsUrl: base ? `${base}/gws` : null,
3224
- clientId: OAUTH_CLIENT_ID,
3225
- clientSecret: OAUTH_CLIENT_SECRET,
3371
+ note: "OAuth 2.1 public client — Claude registers dynamically, no client_secret needed",
3226
3372
  });
3227
3373
  }
3228
3374
 
3229
- // POST /roll-mcp-creds — generate new client ID/secret, invalidate all tokens
3375
+ // POST /roll-mcp-creds — invalidate all tokens and clear all dynamic clients
3230
3376
  if (method === "POST" && reqPath === "/roll-mcp-creds") {
3231
3377
  if (lockedGuard(res)) return;
3232
- const newId = crypto.randomBytes(16).toString("hex");
3233
- const newSecret = crypto.randomBytes(32).toString("hex");
3234
- // Update in-memory
3235
- oauthClients.delete(OAUTH_CLIENT_ID);
3236
- OAUTH_CLIENT_ID = newId;
3237
- OAUTH_CLIENT_SECRET = newSecret;
3238
- stableCreds.client_id = newId;
3239
- stableCreds.client_secret = newSecret;
3240
- try { fs.writeFileSync(CREDS_FILE, JSON.stringify(stableCreds)); } catch {}
3241
- // Register new client
3242
- oauthClients.set(newId, {
3243
- client_id: newId, client_secret: newSecret,
3244
- client_name: "claude.ai", redirect_uris: ["https://claude.ai/api/mcp/auth_callback"],
3245
- grant_types: ["authorization_code"], response_types: ["code"],
3246
- token_endpoint_auth_method: "client_secret_post",
3247
- });
3248
- // Invalidate all existing tokens
3378
+ // Clear all dynamic clients and tokens
3379
+ oauthClients.clear();
3249
3380
  oauthTokens.clear();
3250
3381
  saveTokens(oauthTokens);
3251
- const logMsg = `[${new Date().toISOString()}] OAuth: rolled credentials — new client ${newId.slice(0,8)}…, all tokens invalidated\n`;
3382
+ const logMsg = `[${new Date().toISOString()}] OAuth: rolled credentials — all clients and tokens invalidated\n`;
3252
3383
  try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
3253
- return ok(res, { client_id: newId, client_secret: newSecret, tokens_invalidated: true });
3384
+ return ok(res, { clients_cleared: true, tokens_invalidated: true });
3254
3385
  }
3255
3386
 
3256
3387
  // POST /tunnel — start or stop tunnel manually (action in body)
@@ -4319,18 +4450,10 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4319
4450
  }
4320
4451
  }
4321
4452
 
4322
- // OAuth paths — return plain HTML 404 (not JSON) to match Express/regen-media pattern.
4323
- // claude.ai treats JSON 404 as "OAuth endpoint exists but errored" vs HTML 404 = "path doesn't exist"
4324
- if (["/register", "/authorize", "/token"].includes(reqPath) ||
4325
- reqPath.startsWith("/.well-known/oauth")) {
4326
- res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
4327
- return res.end("<!DOCTYPE html><html><head><title>404</title></head><body><h1>Not Found</h1></body></html>");
4328
- }
4329
-
4330
4453
  // Unknown route — don't count browser/MCP noise as auth failures
4331
4454
  const isBenign = reqPath.startsWith("/.well-known/") || [
4332
4455
  "/favicon.ico", "/robots.txt", "/apple-touch-icon.png", "/apple-touch-icon-precomposed.png",
4333
- "/sse", "/mcp", "/gws", "/clauth", "/message", "/shutdown", "/restart",
4456
+ "/sse", "/mcp", "/gws", "/clauth", "/message", "/register", "/authorize", "/token", "/shutdown", "/restart",
4334
4457
  ].includes(reqPath);
4335
4458
  if (isBenign) {
4336
4459
  res.writeHead(404, { "Content-Type": "application/json", ...CORS });
@@ -4339,8 +4462,8 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4339
4462
  return strike(res, 404, `Unknown endpoint: ${reqPath}`);
4340
4463
  });
4341
4464
 
4342
- server.__oauthClientId = OAUTH_CLIENT_ID;
4343
- server.__oauthClientSecret = OAUTH_CLIENT_SECRET;
4465
+ // OAuth 2.1 public client — no static credentials to expose
4466
+ server.__oauthClients = oauthClients;
4344
4467
  return server;
4345
4468
  }
4346
4469
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/clauth",
3
- "version": "1.5.34",
3
+ "version": "1.5.36",
4
4
  "description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
5
5
  "type": "module",
6
6
  "bin": {